diff --git a/audit/BUSINESS_FLOW_TEST_REPORT.md b/audit/BUSINESS_FLOW_TEST_REPORT.md new file mode 100644 index 0000000..b86ece5 --- /dev/null +++ b/audit/BUSINESS_FLOW_TEST_REPORT.md @@ -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 当前表限定 `""."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;本轮未批量改写这些非业务告警。 diff --git a/backend/api/cmd/cli/main.go b/backend/api/cmd/cli/main.go index be9774b..ff44e5b 100644 --- a/backend/api/cmd/cli/main.go +++ b/backend/api/cmd/cli/main.go @@ -179,11 +179,10 @@ func migrateDatabase() error { if config.Spec.Databases == nil { return fmt.Errorf("database configuration is required") } - var migrationDatabase interface { - Migrator() gorm.Migrator - } + var migrationDatabase *gorm.DB var err error - switch strings.ToLower(config.Spec.Databases.Driver) { + driver := strings.ToLower(config.Spec.Databases.Driver) + switch driver { case "postgres": migrationDatabase, err = database.NewPostgres(config.Spec.Databases.Source, nil) case "mysql": @@ -194,6 +193,9 @@ func migrateDatabase() error { if err != nil { return fmt.Errorf("connect database before migration: %w", err) } + if err := prepareAdditiveMigrations(migrationDatabase, driver); err != nil { + return err + } const legacyPhoneIndex = "idx_platform_account_phone" if migrationDatabase.Migrator().HasIndex(&models.PlatformAccount{}, legacyPhoneIndex) { if err := migrationDatabase.Migrator().DropIndex(&models.PlatformAccount{}, legacyPhoneIndex); err != nil { @@ -210,3 +212,41 @@ func migrateDatabase() error { } 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 +} diff --git a/backend/api/cmd/cli/main_test.go b/backend/api/cmd/cli/main_test.go new file mode 100644 index 0000000..a30b6f8 --- /dev/null +++ b/backend/api/cmd/cli/main_test.go @@ -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) + } +} diff --git a/backend/api/internal/logic/common/base.go b/backend/api/internal/logic/common/base.go index e56b52e..2cb4d52 100644 --- a/backend/api/internal/logic/common/base.go +++ b/backend/api/internal/logic/common/base.go @@ -15,6 +15,7 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // AccountPasswordMinLength 是账号密码允许的最低字符数。 @@ -79,7 +80,10 @@ func IsGenericRecordStatus(status int) bool { // ActiveRecords excludes logically archived records from operational queries. 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) { diff --git a/backend/api/internal/logic/common/resource_test.go b/backend/api/internal/logic/common/resource_test.go index e650f83..72f5eb8 100644 --- a/backend/api/internal/logic/common/resource_test.go +++ b/backend/api/internal/logic/common/resource_test.go @@ -31,11 +31,34 @@ func TestOperationalQueriesExcludeArchivedRecords(t *testing.T) { statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB { 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) } } +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) { got := ResourceResponse(map[string]any{ "id": uint64(1), "identity": "root", diff --git a/backend/api/internal/logic/delivery/finance.go b/backend/api/internal/logic/delivery/finance.go index df56ae6..697ba29 100644 --- a/backend/api/internal/logic/delivery/finance.go +++ b/backend/api/internal/logic/delivery/finance.go @@ -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 ListRecord(ctx *gin.Context) { listWalletChild(ctx, &models.WalletRecord{}, "wallet_record") } 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) { 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 GetRecord(ctx *gin.Context) { getWalletChild(ctx, &models.WalletRecord{}, "wallet_record") } 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) { getWalletChild(ctx, &models.WalletApplyCash{}, "wallet_apply_cash") } diff --git a/backend/api/internal/logic/gas/delivery_account.go b/backend/api/internal/logic/gas/delivery_account.go index 41abf74..63f5a89 100644 --- a/backend/api/internal/logic/gas/delivery_account.go +++ b/backend/api/internal/logic/gas/delivery_account.go @@ -7,8 +7,15 @@ import ( "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "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) { station, ok := currentGas(ctx) if !ok { @@ -18,9 +25,7 @@ func ListDeliveryAccount(ctx *gin.Context) { var list []models.DeliveryAccount var total int64 query := common.ApplyKeywordFilter(ctx, - common.ActiveRecords(impl.DBService.Model(&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{}) + scopedDeliveryAccounts(impl.DBService, station.ID), &models.DeliveryAccount{}) if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return @@ -37,9 +42,8 @@ func GetDeliveryAccount(ctx *gin.Context) { if !ok { return } - query := common.ActiveRecords(impl.DBService.Model(&models.DeliveryAccount{})). - Joins("JOIN delivery_basic ON delivery_basic.id = delivery_account.delivery_basic_id"). - Where("delivery_account.identity = ? AND delivery_basic.gas_basic_id = ?", ctx.Param("identity"), station.ID) + query := scopedDeliveryAccounts(impl.DBService, station.ID). + Where("delivery_account.identity = ?", ctx.Param("identity")) respondScopedRecord(ctx, query, &models.DeliveryAccount{}) } @@ -84,10 +88,9 @@ func UpdateDeliveryAccount(ctx *gin.Context) { return } var account models.DeliveryAccount - if err := common.ActiveRecords(impl.DBService.Model(&models.DeliveryAccount{})). + if err := scopedDeliveryAccounts(impl.DBService, station.ID). Select("delivery_account.*"). - Joins("JOIN delivery_basic ON delivery_basic.id = delivery_account.delivery_basic_id"). - Where("delivery_account.identity = ? AND delivery_basic.gas_basic_id = ?", ctx.Param("identity"), station.ID). + Where("delivery_account.identity = ?", ctx.Param("identity")). First(&account).Error; err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -108,10 +111,9 @@ func UpdateDeliveryAccountStatus(ctx *gin.Context) { return } var account models.DeliveryAccount - if err := common.ActiveRecords(impl.DBService.Model(&models.DeliveryAccount{})). + if err := scopedDeliveryAccounts(impl.DBService, station.ID). Select("delivery_account.*"). - Joins("JOIN delivery_basic ON delivery_basic.id = delivery_account.delivery_basic_id"). - Where("delivery_account.identity = ? AND delivery_basic.gas_basic_id = ?", ctx.Param("identity"), station.ID). + Where("delivery_account.identity = ?", ctx.Param("identity")). First(&account).Error; err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return diff --git a/backend/api/internal/logic/gas/delivery_account_test.go b/backend/api/internal/logic/gas/delivery_account_test.go new file mode 100644 index 0000000..a771383 --- /dev/null +++ b/backend/api/internal/logic/gas/delivery_account_test.go @@ -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) + } +} diff --git a/backend/api/internal/routers/delivery.go b/backend/api/internal/routers/delivery.go index b2d3e4a..fbd3135 100644 --- a/backend/api/internal/routers/delivery.go +++ b/backend/api/internal/routers/delivery.go @@ -88,6 +88,8 @@ func RegisterDelivery(serviceKey string, engine *gin.Engine) { protected.GET("/wallet_basic", deliverylogic.ListWallet) protected.GET("/wallet_basic/:identity", deliverylogic.GetWallet) 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.GET("/wallet_bank", deliverylogic.ListBank) protected.GET("/wallet_bank/:identity", deliverylogic.GetBank) diff --git a/backend/api/internal/routers/delivery_test.go b/backend/api/internal/routers/delivery_test.go index bdadbc6..0fec593 100644 --- a/backend/api/internal/routers/delivery_test.go +++ b/backend/api/internal/routers/delivery_test.go @@ -43,6 +43,8 @@ func TestDeliveryOrderActionBoundary(t *testing.T) { "POST /heqi/delivery/v1/gasorder_basic/:identity/assign", "POST /heqi/delivery/v1/gasorder_basic/:identity/reclaim", "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] { t.Fatalf("missing confirmed delivery action %s", required) diff --git a/frontend/delivery_admin/src/api/http.ts b/frontend/delivery_admin/src/api/http.ts index 3c55990..5fdc1ef 100644 --- a/frontend/delivery_admin/src/api/http.ts +++ b/frontend/delivery_admin/src/api/http.ts @@ -1,12 +1,12 @@ /** 配送点后台 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 = { total: number; list: T[] }; export async function request(path: string, init?: RequestInit): Promise { - const token = localStorage.getItem(tokenStorageKey); + const token = getToken(); let response: Response; try { response = await fetch(`${apiBaseURL}${path}`, { diff --git a/frontend/delivery_admin/src/contracts/delivery-resources.json b/frontend/delivery_admin/src/contracts/delivery-resources.json index 36fde68..bce36f2 100644 --- a/frontend/delivery_admin/src/contracts/delivery-resources.json +++ b/frontend/delivery_admin/src/contracts/delivery-resources.json @@ -1 +1 @@ -{"resources":[{"domain":"profile","name":"delivery_profile","path":"/delivery_profile","pageKind":"list","mode":"readonly"},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"contract","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed"},{"domain":"contract","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"contract","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"contract","name":"product_info","path":"/product_info","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only"},{"domain":"finance","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_payment","path":"/wallet_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_refund","path":"/wallet_refund","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_recharge","path":"/wallet_recharge","pageKind":"list","mode":"append_only"},{"domain":"finance","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"append_only"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"readonly"}],"routes":[{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/adjust-amount"},{"method":"POST","path":"/gasorder_basic/:identity/reclaim"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/wallet_basic/recharge"},{"method":"POST","path":"/wallet_recharge"},{"method":"POST","path":"/wallet_apply_cash"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/auth/login"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_refund"},{"method":"GET","path":"/wallet_refund/:identity"},{"method":"GET","path":"/wallet_payment"},{"method":"GET","path":"/wallet_payment/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/dashboard/reports"},{"method":"GET","path":"/delivery_menu"},{"method":"GET","path":"/delivery_profile"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"GET","path":"/invitation/qrcode"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_account/:identity/password"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_account/:identity/password"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"}]} +{"resources":[{"domain":"profile","name":"delivery_profile","path":"/delivery_profile","pageKind":"list","mode":"readonly"},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"contract","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed"},{"domain":"contract","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"contract","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"contract","name":"product_info","path":"/product_info","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only"},{"domain":"finance","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_payment","path":"/wallet_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_refund","path":"/wallet_refund","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_recharge","path":"/wallet_recharge","pageKind":"list","mode":"append_only"},{"domain":"finance","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"append_only"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"readonly"}],"routes":[{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/adjust-amount"},{"method":"POST","path":"/gasorder_basic/:identity/reclaim"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/wallet_basic/recharge"},{"method":"POST","path":"/wallet_recharge"},{"method":"POST","path":"/wallet_apply_cash"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/auth/login"},{"method":"GET","path":"/wallet_recharge"},{"method":"GET","path":"/wallet_recharge/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_refund"},{"method":"GET","path":"/wallet_refund/:identity"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_payment"},{"method":"GET","path":"/wallet_payment/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/dashboard/reports"},{"method":"GET","path":"/delivery_menu"},{"method":"GET","path":"/delivery_profile"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"GET","path":"/invitation/qrcode"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_account/:identity/password"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_account/:identity/password"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"}]} diff --git a/frontend/delivery_admin/src/router/guard/permission.ts b/frontend/delivery_admin/src/router/guard/permission.ts index 1a481a8..770ff90 100644 --- a/frontend/delivery_admin/src/router/guard/permission.ts +++ b/frontend/delivery_admin/src/router/guard/permission.ts @@ -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(); } else next(NOT_FOUND); } else if (permissionsAllow) { diff --git a/frontend/delivery_admin/src/utils/auth.ts b/frontend/delivery_admin/src/utils/auth.ts index 2250b8a..4b404de 100644 --- a/frontend/delivery_admin/src/utils/auth.ts +++ b/frontend/delivery_admin/src/utils/auth.ts @@ -1,4 +1,4 @@ -const TOKEN_KEY = 'token'; +const TOKEN_KEY = 'delivery_admin_token'; const isLogin = () => { return !!localStorage.getItem(TOKEN_KEY); diff --git a/frontend/gas_admin/src/api/http.ts b/frontend/gas_admin/src/api/http.ts index 67c4c08..96c42bc 100644 --- a/frontend/gas_admin/src/api/http.ts +++ b/frontend/gas_admin/src/api/http.ts @@ -1,12 +1,12 @@ /** 气站后台 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 = { total: number; list: T[] }; export async function request(path: string, init?: RequestInit): Promise { - const token = localStorage.getItem(tokenStorageKey); + const token = getToken(); let response: Response; try { response = await fetch(`${apiBaseURL}${path}`, { diff --git a/frontend/gas_admin/src/router/guard/permission.ts b/frontend/gas_admin/src/router/guard/permission.ts index 1a481a8..770ff90 100644 --- a/frontend/gas_admin/src/router/guard/permission.ts +++ b/frontend/gas_admin/src/router/guard/permission.ts @@ -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(); } else next(NOT_FOUND); } else if (permissionsAllow) { diff --git a/frontend/gas_admin/src/utils/auth.ts b/frontend/gas_admin/src/utils/auth.ts index 2250b8a..8ac1b59 100644 --- a/frontend/gas_admin/src/utils/auth.ts +++ b/frontend/gas_admin/src/utils/auth.ts @@ -1,4 +1,4 @@ -const TOKEN_KEY = 'token'; +const TOKEN_KEY = 'gas_admin_token'; const isLogin = () => { return !!localStorage.getItem(TOKEN_KEY); diff --git a/frontend/platform_admin/src/router/guard/permission.ts b/frontend/platform_admin/src/router/guard/permission.ts index 1a481a8..770ff90 100644 --- a/frontend/platform_admin/src/router/guard/permission.ts +++ b/frontend/platform_admin/src/router/guard/permission.ts @@ -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(); } else next(NOT_FOUND); } else if (permissionsAllow) {