修复气站合同用户权限与服务关系边界

This commit is contained in:
czl231
2026-08-18 22:49:14 +08:00
parent 61a61f916b
commit 9a31cadf5f
27 changed files with 635 additions and 59 deletions

View File

@@ -1,3 +1,5 @@
// 功能描述:实现气站范围内的配送合同、合同气瓶和燃气配送订单接口。
// 版本v1.1.0
package gas
import (
@@ -5,6 +7,8 @@ import (
"encoding/json"
"io"
"reflect"
"strings"
"time"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
@@ -59,8 +63,26 @@ func ListGasorderContract(ctx *gin.Context) {
if !ok {
return
}
listScoped(ctx, &models.GasorderContract{}, common.ActiveRecords(impl.DBService.Model(&models.GasorderContract{})).
Where("gas_basic_id = ?", station.ID), "gasorder_contract.created_at desc")
page, size := common.PageSize(ctx)
query := common.ApplyKeywordFilter(ctx,
platformgasorder.ContractPartyDisplayQuery(impl.DBService).
Where("gasorder_contract.gas_basic_id = ?", station.ID),
&models.GasorderContract{})
candidate := strings.TrimSpace(ctx.Query("candidate"))
if candidate == "order" || candidate == "binding" {
query = platformgasorder.FilterGasorderContractCandidates(query, candidate, time.Now())
}
var total int64
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
var list []platformgasorder.ContractPartyDisplay
if err := query.Order("gasorder_contract.created_at desc").Offset((page - 1) * size).Limit(size).Scan(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
respondList(ctx, list, total)
}
func GetGasorderContract(ctx *gin.Context) {

View File

@@ -58,22 +58,56 @@ func requireStaff(ctx *gin.Context, identity string, gasID uint64) (models.Staff
}
func requireUser(ctx *gin.Context, identity string, gasID uint64) (models.UserAccount, models.UserServiceRelation, bool) {
user, relation, err := findCurrentGasUser(impl.DBService, identity, gasID)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return models.UserAccount{}, models.UserServiceRelation{}, false
}
return user, relation, true
}
// findCurrentGasUser 查询当前气站有效服务关系内的用户,不直接写入 HTTP 响应,供只读合同兜底复用。
func findCurrentGasUser(databaseService *gorm.DB, identity string, gasID uint64) (models.UserAccount, models.UserServiceRelation, error) {
var user models.UserAccount
var relation models.UserServiceRelation
err := common.ActiveRecords(impl.DBService.Model(&models.UserAccount{})).
err := common.ActiveRecords(databaseService.Model(&models.UserAccount{})).
Select("user_account.*").
Joins("JOIN user_service_relation ON user_service_relation.user_account_id = user_account.id AND user_service_relation.status <> ?",
common.StatusArchived).
Where("user_account.identity = ? AND user_service_relation.gas_basic_id = ?", identity, gasID).
First(&user).Error
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return models.UserAccount{}, models.UserServiceRelation{}, false
return models.UserAccount{}, models.UserServiceRelation{}, err
}
if err := common.ActiveRecords(impl.DBService).
if err := common.ActiveRecords(databaseService).
Where("user_account_id = ? AND gas_basic_id = ?", user.ID, gasID).First(&relation).Error; err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return models.UserAccount{}, models.UserServiceRelation{}, false
return models.UserAccount{}, models.UserServiceRelation{}, err
}
return user, relation, true
return user, relation, nil
}
// contractScopedUserRecord 带出合同保存的默认配送点,供无服务关系用户只读展示本站合同上下文。
type contractScopedUserRecord struct {
models.UserAccount
ContractDeliveryBasicID uint64 `gorm:"column:contract_delivery_basic_id"`
}
// findContractScopedUser 仅查询当前气站合同中实际引用的用户,禁止借详情接口枚举全局用户。
func findContractScopedUser(databaseService *gorm.DB, identity string, gasID uint64, contractIdentity string) (contractScopedUserRecord, error) {
var record contractScopedUserRecord
err := contractScopedUserQuery(databaseService, identity, gasID, contractIdentity).
First(&record).Error
return record, err
}
// contractScopedUserQuery 统一合同用户只读详情与头像的气站范围查询条件。
func contractScopedUserQuery(databaseService *gorm.DB, identity string, gasID uint64, contractIdentity string) *gorm.DB {
query := common.ActiveRecords(databaseService.Model(&models.UserAccount{})).
Select("user_account.*, gasorder_contract.delivery_basic_id AS contract_delivery_basic_id").
Joins("JOIN gasorder_contract ON gasorder_contract.user_account_id = user_account.id AND gasorder_contract.status <> ?", common.StatusArchived).
Where("user_account.identity = ? AND gasorder_contract.gas_basic_id = ?", identity, gasID)
if contractIdentity != "" {
query = query.Where("gasorder_contract.identity = ?", contractIdentity)
}
return query.Order("gasorder_contract.created_at DESC")
}

View File

@@ -17,6 +17,12 @@ type gasUserListItem struct {
DeliveryBasicID uint64 `gorm:"column:delivery_basic_id" json:"delivery_basic_id"`
}
// gasUserDetail 标识用户是否仅因当前气站合同而可见,前端据此进入只读模式。
type gasUserDetail struct {
models.UserAccount
ContractReadonly bool `json:"contract_readonly"`
}
// scopedUserList 将用户列表与当前气站唯一有效服务关系绑定,避免返回跨站归属。
func scopedUserList(databaseService *gorm.DB, gasBasicID uint64) *gorm.DB {
return common.ActiveRecords(databaseService.Model(&models.UserAccount{})).
@@ -52,8 +58,35 @@ func GetUser(ctx *gin.Context) {
if !ok {
return
}
user, relation, ok := requireUser(ctx, ctx.Param("identity"), station.ID)
if !ok {
user, relation, err := findCurrentGasUser(impl.DBService, ctx.Param("identity"), station.ID)
contractReadonly := false
var contractDeliveryBasicID uint64
if err != nil {
contractUser, contractErr := findContractScopedUser(
impl.DBService, ctx.Param("identity"), station.ID, ctx.Query("contract_identity"),
)
err = contractErr
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
user = contractUser.UserAccount
contractReadonly = true
contractDeliveryBasicID = contractUser.ContractDeliveryBasicID
}
if contractReadonly {
// 合同范围只返回用户主档,不泄露地址、钱包或其他服务关系上下文。
detail := gasUserDetail{UserAccount: user, ContractReadonly: true}
response, responseErr := common.PublicResourceResponse(gin.H{
"user": detail,
"relation": gin.H{"delivery_basic_id": contractDeliveryBasicID},
"addresses": []models.UserAddress{},
})
if responseErr != nil {
infra.Response.Error(ctx, responseErr)
return
}
infra.Response.Success(ctx, response)
return
}
var addresses []models.UserAddress
@@ -69,15 +102,21 @@ func GetUser(ctx *gin.Context) {
infra.Response.Success(ctx, response)
}
// GetUserAvatar 返回与当前气站存在服务关系的用户受保护头像。
// GetUserAvatar 返回与当前气站存在服务关系或合同引用关系的用户受保护头像。
func GetUserAvatar(ctx *gin.Context) {
station, ok := currentGas(ctx)
if !ok {
return
}
user, _, ok := requireUser(ctx, ctx.Param("identity"), station.ID)
if !ok {
return
user, _, err := findCurrentGasUser(impl.DBService, ctx.Param("identity"), station.ID)
if err != nil {
contractUser, contractErr := findContractScopedUser(impl.DBService, ctx.Param("identity"), station.ID, "")
err = contractErr
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
user = contractUser.UserAccount
}
upload.ServeAvatar(ctx, user.Avatar)
}

View File

@@ -30,3 +30,28 @@ func TestScopedUserListIncludesDeliveryRelation(t *testing.T) {
t.Fatalf("user list must keep gas scope, got: %s", statement)
}
}
// TestContractScopedUserRequiresCurrentGasContract 验证只读用户详情同时受用户标识与当前气站合同约束。
func TestContractScopedUserRequiresCurrentGasContract(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 := contractScopedUserQuery(databaseService, "user-identity", 42, "contract-identity").
Find(&[]contractScopedUserRecord{}).Statement.SQL.String()
if !strings.Contains(statement, "JOIN gasorder_contract ON gasorder_contract.user_account_id = user_account.id") {
t.Fatalf("contract-scoped detail must join contract, got: %s", statement)
}
if !strings.Contains(statement, "user_account.identity =") || !strings.Contains(statement, "gasorder_contract.gas_basic_id =") {
t.Fatalf("contract-scoped detail must keep user and gas scope, got: %s", statement)
}
if !strings.Contains(statement, "gasorder_contract.identity =") || !strings.Contains(statement, "contract_delivery_basic_id") {
t.Fatalf("contract-scoped detail must keep contract context and delivery point, got: %s", statement)
}
}

View File

@@ -0,0 +1,45 @@
// 功能描述:为配送合同提供签约用户名称和当前服务关系状态,不新增数据库字段。
// 版本v1.0.0
package gasorder
import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"gorm.io/gorm"
)
// ContractPartyDisplay 在合同主档上附加可读用户名称和当前气站服务关系状态。
type ContractPartyDisplay struct {
models.GasorderContract
UserAccountDisplayName string `gorm:"column:user_account_display_name" json:"user_account_display_name"`
UserServiceActive bool `gorm:"column:user_service_active" json:"user_service_active"`
}
// ContractPartyDisplayQuery 仅通过合同已保存的用户主键读取名称,不开放平台用户全集。
func ContractPartyDisplayQuery(databaseService *gorm.DB) *gorm.DB {
return common.ActiveRecords(databaseService.Model(&models.GasorderContract{})).
Select(`gasorder_contract.*,
COALESCE(NULLIF(contract_user.name, ''), NULLIF(contract_user.real_name, ''), NULLIF(contract_user.username, ''), '签约用户记录已失效') AS user_account_display_name,
EXISTS (
SELECT 1 FROM user_service_relation AS contract_relation
WHERE contract_relation.user_account_id = gasorder_contract.user_account_id
AND contract_relation.gas_basic_id = gasorder_contract.gas_basic_id
AND contract_relation.status <> ?
AND contract_relation.deleted_at IS NULL
) AS user_service_active`, common.StatusArchived).
Joins("LEFT JOIN user_account AS contract_user ON contract_user.id = gasorder_contract.user_account_id")
}
// contractUserServiceActive 校验签约用户当前仍由合同气站服务。
func contractUserServiceActive(databaseService *gorm.DB, contract models.GasorderContract) bool {
return userServiceActiveForGas(databaseService, contract.UserAccountID, contract.GasBasicID)
}
// userServiceActiveForGas 校验全局用户当前是否分配给指定气站。
func userServiceActiveForGas(databaseService *gorm.DB, userAccountID, gasBasicID uint64) bool {
var count int64
err := databaseService.Model(&models.UserServiceRelation{}).
Where("user_account_id = ? AND gas_basic_id = ? AND status <> ?", userAccountID, gasBasicID, common.StatusArchived).
Count(&count).Error
return err == nil && count > 0
}

View File

@@ -1,5 +1,5 @@
// 功能描述:实现平台配送合同、配送订单及履约状态动作。
// 版本v1.3.0
// 版本v1.4.0
package gasorder
import (
@@ -31,6 +31,7 @@ var (
errContractNotEffective = errors.New("合同尚未到生效时间,暂时不能启用")
errContractExpired = errors.New("合同已到期,请调整到期时间或续签后再启用")
errContractNoProduct = errors.New("合同尚未绑定有效智能气阀。请先点击“绑定气阀”;若无可选设备,请到“智能气阀管理”将设备归属变更为合同用户,并确认设备已启用、未报废")
errContractUserOutsideGas = errors.New("签约用户已不属于当前气站,只允许查看或终止合同")
errBindingContractStatus = errors.New("仅草稿合同可以绑定气瓶")
errBindingProductDisabled = errors.New("所选气瓶已停用,不能绑定到合同")
errBindingProductScrapped = errors.New("所选气瓶已报废,不能绑定到合同")
@@ -52,18 +53,38 @@ var (
// ListGasorderContract 按明确用途收窄合同候选;合同管理列表不带用途参数时保持全量查询。
func ListGasorderContract(ctx *gin.Context) {
candidate := strings.TrimSpace(ctx.Query("candidate"))
if candidate != "order" && candidate != "binding" {
common.ListResource(ctx, &models.GasorderContract{})
page, size := common.PageSize(ctx)
query := common.ApplyKeywordFilter(ctx, ContractPartyDisplayQuery(impl.DBService), &models.GasorderContract{})
if candidate == "order" || candidate == "binding" {
query = filterGasorderContractCandidates(query, candidate, time.Now())
}
var total int64
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
now := time.Now()
common.ListPageFiltered[models.GasorderContract](ctx, func(query *gorm.DB) *gorm.DB {
return filterGasorderContractCandidates(query, candidate, now)
})
var list []ContractPartyDisplay
if err := query.Order("gasorder_contract.created_at desc").Offset((page - 1) * size).Limit(size).Scan(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
response, err := common.PublicResourceResponse(list)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "list": response})
}
// filterGasorderContractCandidates 应用订单履约或草稿绑定场景的合同候选条件。
func filterGasorderContractCandidates(query *gorm.DB, candidate string, now time.Time) *gorm.DB {
// FilterGasorderContractCandidates 应用订单履约或草稿绑定场景的合同候选条件。
func FilterGasorderContractCandidates(query *gorm.DB, candidate string, now time.Time) *gorm.DB {
query = query.Where(`EXISTS (
SELECT 1 FROM user_service_relation AS candidate_relation
WHERE candidate_relation.user_account_id = gasorder_contract.user_account_id
AND candidate_relation.gas_basic_id = gasorder_contract.gas_basic_id
AND candidate_relation.status <> ?
AND candidate_relation.deleted_at IS NULL
)`, common.StatusArchived)
if candidate == "binding" {
return query.Where("contract_status = ?", common.StatusDraft)
}
@@ -74,6 +95,11 @@ func filterGasorderContractCandidates(query *gorm.DB, candidate string, now time
now,
)
}
// filterGasorderContractCandidates 保留包内调用名称,统一委托给公开的范围过滤器。
func filterGasorderContractCandidates(query *gorm.DB, candidate string, now time.Time) *gorm.DB {
return FilterGasorderContractCandidates(query, candidate, now)
}
func GetGasorderContract(ctx *gin.Context) { getGasorderContract(ctx) }
// filterGasorderContractProductCandidates 仅保留当前合同仍有效绑定的气瓶,并按类型和编码排序。
@@ -105,11 +131,16 @@ func ListGasorderPayment(ctx *gin.Context) { common.ListResource(ctx, &models
func GetGasorderPayment(ctx *gin.Context) { common.GetResource(ctx, &models.GasorderPayment{}) }
func getGasorderContract(ctx *gin.Context) {
var contract models.GasorderContract
if err := common.ActiveRecords(impl.DBService).Where("identity = ?", ctx.Param("identity")).First(&contract).Error; err != nil {
var display ContractPartyDisplay
if err := ContractPartyDisplayQuery(impl.DBService).Where("gasorder_contract.identity = ?", ctx.Param("identity")).Scan(&display).Error; err != nil {
common.RespondRecordError(ctx, err)
return
}
contract := display.GasorderContract
if contract.ID == 0 {
common.RespondRecordError(ctx, gorm.ErrRecordNotFound)
return
}
var products []gasorderContractProductDisplay
var revisions []models.GasorderContractRevision
if err := contractProductDisplayQuery(impl.DBService, "").
@@ -124,7 +155,7 @@ func getGasorderContract(ctx *gin.Context) {
return
}
response, err := common.PublicResourceResponse(gin.H{
"contract": contract, "products": products, "revisions": revisions,
"contract": display, "products": products, "revisions": revisions,
"attachment": contractAttachmentInfo(contract),
})
if err != nil {
@@ -238,6 +269,10 @@ func CreateGasorderContract(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if !userServiceActiveForGas(impl.DBService, userID, gasID) {
infra.Response.Error(ctx, errContractUserOutsideGas)
return
}
deliveryID, err := common.ResolveIdentityID(&models.DeliveryBasic{}, request.DeliveryIdentity, false)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
@@ -307,6 +342,10 @@ func UpdateGasorderContract(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if !contractUserServiceActive(impl.DBService, contract) {
infra.Response.Error(ctx, errContractUserOutsideGas)
return
}
if request.RemoveAttachment && request.AttachmentReceipt != "" {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
@@ -400,6 +439,9 @@ func RenewGasorderContract(ctx *gin.Context) {
if contract.ContractStatus != common.StatusActive && contract.ContractStatus != common.StatusExpired && contract.ContractStatus != common.StatusTerminated {
return errors.New("contract cannot be renewed")
}
if !contractUserServiceActive(tx, contract) {
return errContractUserOutsideGas
}
if err := tx.Model(&contract).Updates(map[string]any{"contract_status": common.StatusActive, "effective_at": request.EffectiveAt, "expired_at": request.ExpiredAt}).Error; err != nil {
return err
}
@@ -407,7 +449,11 @@ func RenewGasorderContract(ctx *gin.Context) {
return tx.Create(contractRevision(contract, "renew", request.Reason, operatorIdentity, operatorName)).Error
})
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
if errors.Is(err, errContractUserOutsideGas) {
infra.Response.Error(ctx, err)
} else {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
}
return
}
infra.Response.Success(ctx, gin.H{"updated": true})
@@ -430,6 +476,9 @@ func changeGasorderContract(ctx *gin.Context, action string, target int) {
if action == "activate" && !contractCanActivate(contract.ContractStatus) {
return errContractCannotActivate
}
if action == "activate" && !contractUserServiceActive(tx, contract) {
return errContractUserOutsideGas
}
if action == "terminate" && contract.ContractStatus != common.StatusActive {
return errContractCannotTerminate
}
@@ -483,7 +532,8 @@ func isContractChangeBusinessError(err error) bool {
errors.Is(err, errContractAttachment) ||
errors.Is(err, errContractNotEffective) ||
errors.Is(err, errContractExpired) ||
errors.Is(err, errContractNoProduct)
errors.Is(err, errContractNoProduct) ||
errors.Is(err, errContractUserOutsideGas)
}
func contractRevision(contract models.GasorderContract, action, reason, operatorIdentity, operatorName string) *models.GasorderContractRevision {
@@ -514,6 +564,10 @@ func BindGasorderContractProduct(ctx *gin.Context) {
infra.Response.Error(ctx, errBindingContractStatus)
return
}
if !contractUserServiceActive(impl.DBService, contract) {
infra.Response.Error(ctx, errContractUserOutsideGas)
return
}
var product models.ProductInfo
if err := impl.DBService.Where("identity = ?", request.ProductIdentity).First(&product).Error; err != nil {
common.RespondRecordError(ctx, err)
@@ -567,6 +621,17 @@ func UnbindGasorderContractProduct(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var contract models.GasorderContract
if err := impl.DBService.Model(&models.GasorderContract{}).
Joins("JOIN gasorder_contract_product ON gasorder_contract_product.gasorder_contract_id = gasorder_contract.id").
Where("gasorder_contract_product.identity = ?", ctx.Param("identity")).First(&contract).Error; err != nil {
common.RespondRecordError(ctx, err)
return
}
if !contractUserServiceActive(impl.DBService, contract) {
infra.Response.Error(ctx, errContractUserOutsideGas)
return
}
now := time.Now()
result := impl.DBService.Model(&models.GasorderContractProduct{}).
Where("identity = ? AND unbound_at IS NULL", ctx.Param("identity")).
@@ -617,6 +682,9 @@ func CreateGasorderBasic(ctx *gin.Context) {
if !contractEligibleForOrder(contract, now) {
return errors.New("contract is not active")
}
if !contractUserServiceActive(tx, contract) {
return errContractUserOutsideGas
}
var creatorStaff *models.StaffAccount
if request.CreatorType == "staff" {
var staff models.StaffAccount
@@ -744,7 +812,8 @@ func isGasorderCreatorBusinessError(err error) bool {
errors.Is(err, errOrderCreatorGas) ||
errors.Is(err, errOrderCreatorNoDelivery) ||
errors.Is(err, errOrderCreatorDelivery) ||
errors.Is(err, errOrderCreatorStaff)
errors.Is(err, errOrderCreatorStaff) ||
errors.Is(err, errContractUserOutsideGas)
}
// contractEligibleForOrder 校验合同当前确实处于可履约时间窗口。

View File

@@ -1,5 +1,5 @@
// 功能描述:验证平台配送合同、订单创建和调度状态规则。
// 版本v1.2.0
// 版本v1.3.0
package gasorder
import (
@@ -63,7 +63,7 @@ func TestFilterGasorderContractCandidates(t *testing.T) {
})
}
orderSQL := statement("order")
for _, fragment := range []string{"contract_status = 11", "effective_at <=", "expired_at IS NOT NULL", "expired_at >"} {
for _, fragment := range []string{"contract_status = 11", "effective_at <=", "expired_at IS NOT NULL", "expired_at >", "candidate_relation.user_account_id"} {
if !strings.Contains(orderSQL, fragment) {
t.Fatalf("订单合同候选缺少 %q%s", fragment, orderSQL)
}
@@ -74,6 +74,36 @@ func TestFilterGasorderContractCandidates(t *testing.T) {
}
}
// TestContractPartyDisplayQuery 验证合同名称直接按合同用户主键读取,并计算同气站服务关系状态。
func TestContractPartyDisplayQuery(t *testing.T) {
sqlDatabase, _, err := sqlmock.New()
if err != nil {
t.Fatalf("创建模拟数据库失败:%v", err)
}
defer sqlDatabase.Close()
database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{})
if err != nil {
t.Fatalf("创建 GORM 数据库失败:%v", err)
}
statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB {
var contracts []ContractPartyDisplay
return ContractPartyDisplayQuery(tx).
Where("gasorder_contract.gas_basic_id = ?", 42).
Scan(&contracts)
})
for _, fragment := range []string{
"LEFT JOIN user_account AS contract_user",
"user_account_display_name",
"contract_relation.user_account_id = gasorder_contract.user_account_id",
"contract_relation.gas_basic_id = gasorder_contract.gas_basic_id",
"gasorder_contract.gas_basic_id = 42",
} {
if !strings.Contains(statement, fragment) {
t.Fatalf("合同签约用户展示 SQL 缺少 %q%s", fragment, statement)
}
}
}
// TestFilterGasorderContractProductCandidates 验证订单候选仅包含当前合同未解绑气瓶并按业务字段排序。
func TestFilterGasorderContractProductCandidates(t *testing.T) {
sqlDatabase, _, err := sqlmock.New()

View File

@@ -1,5 +1,5 @@
// 功能描述:实现平台总后台的用户地址与服务关系管理。
// 版本v1.1
// 版本v1.2
package user
import (
@@ -176,6 +176,8 @@ type serviceRelationRequest struct {
StaffAccountIdentity string `json:"staff_account_identity"`
}
var errUserServiceTransferBlocked = errors.New("用户存在生效合同、未完成订单或未关闭工单,请处理完成后再变更所属气站")
func ListUserServiceRelation(ctx *gin.Context) { common.ListPage[models.UserServiceRelation](ctx) }
func GetUserServiceRelation(ctx *gin.Context) { common.GetByIdentity[models.UserServiceRelation](ctx) }
func CreateUserServiceRelation(ctx *gin.Context) {
@@ -206,7 +208,7 @@ func UpdateUserServiceRelation(ctx *gin.Context) {
return
}
var current models.UserServiceRelation
if err := common.ActiveRecords(impl.DBService).Select("user_account_id").
if err := common.ActiveRecords(impl.DBService).Select("user_account_id", "gas_basic_id").
Where("identity = ?", ctx.Param("identity")).First(&current).Error; err != nil {
common.RespondRecordError(ctx, err)
return
@@ -215,9 +217,88 @@ func UpdateUserServiceRelation(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if current.GasBasicID != gasBasicID {
blocked, err := userServiceTransferBlocked(impl.DBService, current)
if err != nil {
infra.Response.Error(ctx, err)
return
}
if blocked {
infra.Response.Error(ctx, errUserServiceTransferBlocked)
return
}
}
common.UpdateAllowedByIdentity(ctx, &models.UserServiceRelation{}, gin.H{"user_account_id": userAccountID, "gas_basic_id": gasBasicID, "delivery_basic_id": deliveryBasicID, "staff_account_id": staffAccountID}, []string{"user_account_id", "gas_basic_id", "delivery_basic_id", "staff_account_id"})
}
// UpdateUserServiceRelationStatus 更新服务关系状态;归档必须执行履约阻断检查。
func UpdateUserServiceRelationStatus(ctx *gin.Context) {
var request struct {
Status int `json:"status" binding:"required"`
}
if err := ctx.ShouldBindJSON(&request); err != nil || !common.IsGenericRecordStatus(request.Status) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if request.Status == common.StatusArchived && !allowUserServiceRelationRemoval(ctx) {
return
}
common.UpdateAllowedByIdentity(ctx, &models.UserServiceRelation{}, gin.H{"status": request.Status}, []string{"status"})
}
// ArchiveUserServiceRelation 归档服务关系;存在未完成履约业务时拒绝解除归属。
func ArchiveUserServiceRelation(ctx *gin.Context) {
if !allowUserServiceRelationRemoval(ctx) {
return
}
common.UpdateAllowedByIdentity(ctx, &models.UserServiceRelation{}, gin.H{"status": common.StatusArchived}, []string{"status"})
}
// allowUserServiceRelationRemoval 校验当前服务关系是否允许解除。
func allowUserServiceRelationRemoval(ctx *gin.Context) bool {
var current models.UserServiceRelation
if err := common.ActiveRecords(impl.DBService).Where("identity = ?", ctx.Param("identity")).First(&current).Error; err != nil {
common.RespondRecordError(ctx, err)
return false
}
blocked, err := userServiceTransferBlocked(impl.DBService, current)
if err != nil {
infra.Response.Error(ctx, err)
return false
}
if blocked {
infra.Response.Error(ctx, errUserServiceTransferBlocked)
return false
}
return true
}
// userServiceTransferBlocked 检查原气站仍在履约中的合同、订单和工单。
func userServiceTransferBlocked(databaseService *gorm.DB, relation models.UserServiceRelation) (bool, error) {
if relation.GasBasicID == 0 {
return false, nil
}
checks := []struct {
model any
where string
args []any
}{
{&models.GasorderContract{}, "user_account_id = ? AND gas_basic_id = ? AND status <> ? AND contract_status = ?", []any{relation.UserAccountID, relation.GasBasicID, common.StatusArchived, common.StatusActive}},
{&models.GasorderBasic{}, "user_account_id = ? AND gas_basic_id = ? AND status <> ? AND order_status NOT IN ?", []any{relation.UserAccountID, relation.GasBasicID, common.StatusArchived, []int{common.StatusCompleted, common.StatusCancelled}}},
{&models.CsTicket{}, "user_account_id = ? AND gas_basic_id = ? AND status <> ? AND ticket_status = ?", []any{relation.UserAccountID, relation.GasBasicID, common.StatusArchived, common.StatusOpen}},
}
for _, check := range checks {
var count int64
if err := databaseService.Model(check.model).Where(check.where, check.args...).Count(&count).Error; err != nil {
return false, err
}
if count > 0 {
return true, nil
}
}
return false, nil
}
func resolveServiceRelation(ctx *gin.Context, request serviceRelationRequest) (uint64, uint64, uint64, uint64, bool) {
userAccountID, err := common.ResolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true)
if err != nil {

View File

@@ -1,5 +1,5 @@
// 功能描述:验证平台用户地址写入参数与 GORM 的兼容性。
// 版本v1.1
// 版本v1.2
package user
import (
@@ -42,6 +42,52 @@ func TestFilterUserAddressByContract(t *testing.T) {
}
}
// TestUserServiceTransferBlocked 验证生效合同会阻止总后台转移用户所属气站。
func TestUserServiceTransferBlocked(t *testing.T) {
cases := []struct {
name string
counts []int64
want bool
}{
{"生效合同", []int64{1}, true},
{"未完成订单", []int64{0, 1}, true},
{"未关闭工单", []int64{0, 0, 1}, true},
{"无阻断业务", []int64{0, 0, 0}, false},
}
patterns := []string{
`SELECT count\(\*\) FROM "gasorder_contract"`,
`SELECT count\(\*\) FROM "gasorder_basic"`,
`SELECT count\(\*\) FROM "cs_ticket"`,
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
sqlDatabase, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("创建模拟数据库失败:%v", err)
}
defer sqlDatabase.Close()
database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{})
if err != nil {
t.Fatalf("创建 GORM 数据库失败:%v", err)
}
for index, count := range test.counts {
mock.ExpectQuery(patterns[index]).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(count))
}
blocked, err := userServiceTransferBlocked(database, models.UserServiceRelation{UserAccountID: 7, GasBasicID: 9})
if err != nil {
t.Fatalf("检查转移阻断失败:%v", err)
}
if blocked != test.want {
t.Fatalf("转移阻断结果 = %v期望 %v", blocked, test.want)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("数据库预期未满足:%v", err)
}
})
}
}
// TestRestoreUserAddressLocations 验证用户地址列表按原顺序恢复地址与坐标。
func TestRestoreUserAddressLocations(t *testing.T) {
response := []any{

View File

@@ -1,3 +1,5 @@
// 功能描述:注册平台总后台标准资源与业务专用路由。
// 版本v1.1.0
package routers
import (
@@ -200,7 +202,13 @@ func registerUserRoute(group *gin.RouterGroup) {
registerWritableResource(group, "/user_account", userlogic.ListUser, userlogic.CreateUser, userlogic.GetUser, userlogic.UpdateUser, &models.UserAccount{})
group.GET("/user_account/:identity/avatar", userlogic.GetUserAvatar)
registerWritableResource(group, "/user_address", userlogic.ListUserAddress, userlogic.CreateUserAddress, userlogic.GetUserAddress, userlogic.UpdateUserAddress, &models.UserAddress{})
registerWritableResource(group, "/user_service_relation", userlogic.ListUserServiceRelation, userlogic.CreateUserServiceRelation, userlogic.GetUserServiceRelation, userlogic.UpdateUserServiceRelation, &models.UserServiceRelation{})
relation := group.Group("/user_service_relation")
relation.GET("", userlogic.ListUserServiceRelation)
relation.POST("", userlogic.CreateUserServiceRelation)
relation.GET("/:identity", userlogic.GetUserServiceRelation)
relation.PUT("/:identity", userlogic.UpdateUserServiceRelation)
relation.PATCH("/:identity/status", userlogic.UpdateUserServiceRelationStatus)
relation.DELETE("/:identity", userlogic.ArchiveUserServiceRelation)
}
func registerPlatformRoute(group *gin.RouterGroup) {

View File

@@ -168,6 +168,8 @@
用户账户的新建、详情和编辑复用工作人员账户布局,并使用各自独立 URL。用户头像通过受保护资源接口读取不在通用详情响应中暴露头像 URI用户账户列表与平台账户列表沿用工作人员列表的受控缩略图规则。详情页继续展示钱包摘要并可进入钱包独立详情页。
总后台变更或归档用户服务关系时,若原气站仍存在该用户的生效合同、未完成配送订单或未关闭工单,必须拒绝转移并提示先完成相关业务;不得通过先归档关系再新建关系绕过校验。
### 6.4 智能气阀管理
| 资源 | 路径 | 模式 | 已实现能力 |

View File

@@ -87,6 +87,7 @@
### 4.4 用户管理
- 用户账户是平台全局账户;当前气站可管理由总后台分配到本站的用户,也可自行创建用户并在同一事务内建立本站服务关系。
- 支持用户新增、编辑、启停和归档。
- 创建用户时在同一事务内创建其当前气站、当前配送点服务关系。
- 支持当前气站用户地址新增、编辑、启停和归档。
@@ -97,6 +98,8 @@
### 4.5 合同管理
- 配送合同支持创建、草稿编辑、启用、续签和终止。
- 合同列表和详情由合同接口按已保存的签约用户主键返回当前业务名称,不得为了名称展示开放平台用户全集;签约用户记录不存在时显示“签约用户记录已失效”。
- 签约用户已不再属于当前气站时,历史合同仍可查看和终止,但不可编辑、启用、续签、绑定或解绑气瓶,也不可继续创建订单。
- 启用仅适用于草稿合同;已终止合同不得直接启用,只能通过续签填写新的生效时间、到期时间和原因后恢复履约。
- 配送合同创建、草稿编辑和续签必须填写到期时间,且到期时间必须晚于生效时间;历史缺失值应提示补录,不得推测回填。
- 合同只能选择当前气站用户及当前气站配送点。
@@ -162,6 +165,8 @@
- `/staff/add``/gasorder/create` 保留原菜单入口,直接渲染独立新建页。
- 审核、归档确认、重置密码、合同流转、合同气瓶解绑和订单状态动作继续使用短弹窗。
- 详情接口必须校验当前气站数据范围;前端路由隐藏不能替代服务端对象权限。
- 当前服务关系用户可查看完整本站账户上下文;仅被当前气站合同引用的用户只允许查看基础账户详情及该合同保存的本站配送点,不进入本站用户列表,不返回地址和钱包,不允许编辑、重置密码或执行其他账户动作。
- 合同范围用户详情必须同时校验用户业务标识与当前气站合同,不得借此开放平台全局用户查询。
## 6. 菜单输出

View File

@@ -0,0 +1,66 @@
# 气站合同用户权限修复操作日志
操作时间2026-08-18
操作类型:扩展与修复
影响模块:气站配送合同、平台用户服务关系、气站资源列表
## 操作前状态
- 气站合同列表通过本站用户详情接口补载签约用户名称。
- 合同用户已不属于当前气站时,名称补载返回参数错误并显示“名称加载失败”。
- 总后台可在存在未完成履约业务时变更或归档用户服务关系。
- 合同用户转出后,编辑、启用、续签、气瓶绑定和订单创建缺少统一阻断。
## 具体操作
- 合同列表与详情按合同已保存的全局用户主键读取当前业务名称,并随合同响应返回。
- 名称读取始终受合同所属气站范围保护,不新增平台用户全集查询能力。
- 新建合同和订单候选继续限定为与合同气站存在有效服务关系的用户。
- 签约用户转出后,仅保留合同查看和终止能力,阻止其他合同变更及继续履约。
- 总后台变更、归档服务关系前检查生效合同、未完成订单和未关闭工单。
- 未新增字段、未修改表结构、未执行历史数据回填。
- 合同中的用户名称可进入只读账户详情;详情接口必须验证当前气站确实存在引用该用户的合同。
- 合同范围只读详情返回用户基础主档和合同保存的本站配送点,不返回地址、钱包或其他气站服务关系,且前端隐藏编辑、重置密码和全部账户动作入口。
## 操作后状态
- 气站合同列表直接显示签约用户名称,不再请求不属于本站的用户详情。
- 历史合同可以继续识别签约用户;用户记录不存在时显示“签约用户记录已失效”。
- 用户转出后的合同不可继续编辑或履约,仍允许查看和终止。
- 总后台无法通过直接更新、状态归档或删除入口绕过转移阻断。
- 总后台分配合同中的签约用户即使不属于当前气站,也可从合同查看包含该合同配送点的只读账户详情;普通本站用户列表范围保持不变。
## 代码变更
- `backend/api/internal/logic/platform/gasorder/contract_party_display.go`:提供合同签约用户名称和服务关系状态查询。
- `backend/api/internal/logic/platform/gasorder/gasorder.go`:统一合同、绑定、续签和订单创建的用户归属校验。
- `backend/api/internal/logic/gas/gasorder.go`:返回当前气站合同的签约用户显示信息。
- `backend/api/internal/logic/gas/scope.go``user.go`:增加当前气站合同范围用户的只读详情、合同配送点上下文及头像鉴权,写接口仍要求有效服务关系。
- `backend/api/internal/logic/gas/user_test.go`:验证合同用户查询同时受用户标识和当前气站约束。
- `backend/api/internal/logic/platform/user/relation.go`:增加用户跨气站转移和归档阻断。
- `backend/api/internal/routers/platform.go`:为服务关系状态和归档注册业务专用处理器。
- `frontend/gas_admin/src/views/shared/resource-list-field-display.ts`:优先使用合同接口返回的用户名称。
- `frontend/gas_admin/src/views/resource/use-resource-relations.ts`:已有范围内名称时不再补载关联用户。
- `frontend/gas_admin/src/api/resource-page-rules.ts``ResourceRecordPage.vue`:限制用户转出后的合同编辑和动作。
- `frontend/gas_admin/src/api/resource-page-rules.ts``resource-display.ts``ResourceRecordPage.vue``CrudListPage.vue`:传递合同上下文、标记合同范围只读用户,并隐藏编辑、钱包和账户动作。
- `frontend/platform_admin/src` 对应资源页面:同步合同用户名称与转出后的动作限制,保持总后台一致。
## 验证结果
- `go test ./...`:通过,覆盖全部后端包。
- 合同签约用户展示查询与候选范围测试:通过。
- 用户转移的生效合同、未完成订单、未关闭工单及无阻断业务测试:通过。
- `pnpm run type:check`:通过。
- `pnpm run contract:check`通过20 个资源一致。
- `pnpm run resource-pages:check`:通过,详情 20、新建 11、编辑 8。
- `pnpm run build`:通过。
- 平台总后台 `pnpm run type:check``pnpm run resource-pages:check``pnpm run build`:通过。
- 气站合同范围用户详情查询测试:通过;普通服务关系用户详情与写接口范围不变。
## 风险评估
- 名称查询只能从当前合同记录到达一个签约用户,不会扩大气站可枚举的用户范围。
- 历史合同没有名称快照,按“不修改数据库”的约束显示用户当前名称;记录不存在时使用明确失效文案。
- 新增阻断只影响跨气站转移或解除关系,不影响同一气站内调整配送点和服务人员。
- 合同范围详情仅扩展按合同可达的只读路径,不改变用户列表范围;地址、钱包和所有写操作继续要求当前有效服务关系。
- 用户只读详情不展示合同履约内部字段“签约用户仍属当前气站”,统一以“账户权限:仅查看当前合同关联资料”说明访问边界,避免与合同、配送点的气站归属混淆。

View File

@@ -1,6 +1,6 @@
/**
* 功能:静态检查气站管理端全部标准资源是否具备独立记录页配置。
* 版本v1.4.0
* 版本v1.5.0
*/
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
@@ -12,7 +12,10 @@ const routeSource = readFileSync(resolve(root, 'src/router/routes/modules/platfo
const routeBuilderSource = readFileSync(resolve(root, 'src/router/routes/modules/resource-route-builder.ts'), 'utf8');
const ruleSource = readFileSync(resolve(root, 'src/api/resource-page-rules.ts'), 'utf8');
const listSource = readFileSync(resolve(root, 'src/views/shared/CrudListPage.vue'), 'utf8');
const listFieldSource = readFileSync(resolve(root, 'src/views/shared/resource-list-field-display.ts'), 'utf8');
const detailSource = readFileSync(resolve(root, 'src/views/resource/ResourceDetailContent.vue'), 'utf8');
const recordSource = readFileSync(resolve(root, 'src/views/resource/ResourceRecordPage.vue'), 'utf8');
const relationSource = readFileSync(resolve(root, 'src/views/resource/use-resource-relations.ts'), 'utf8');
const displaySource = readFileSync(resolve(root, 'src/api/resource-display.ts'), 'utf8');
const resourceSource = readFileSync(resolve(root, 'src/api/resources.ts'), 'utf8');
const treeResources = new Set();
@@ -96,6 +99,29 @@ if (
) {
fail('用户账户详情未恢复主档与配送点关系上下文');
}
if (
!listFieldSource.includes('const serverName = relationDisplayName(field, row)') ||
!relationSource.includes('if (relationDisplayName(field, values)) return []')
) {
fail('合同列表未优先使用服务端范围内的签约用户名称');
}
if (
!ruleSource.includes('row.user_service_active === false') ||
!recordSource.includes("action.name !== '终止合同'")
) {
fail('签约用户转出后未限制合同编辑和业务动作');
}
if (
!ruleSource.includes('row.contract_readonly === true') ||
!recordSource.includes('record.value.contract_readonly === true') ||
!recordSource.includes('record.contract_readonly !== true') ||
!recordSource.includes("definition.value.name === 'user_account'") ||
!listSource.includes("contract_identity: String(row.identity ?? '')") ||
!displaySource.includes("contract_readonly: '账户权限'") ||
!displaySource.includes("return '仅查看当前合同关联资料'")
) {
fail('合同范围用户详情未恢复配送点上下文或未进入完整只读模式');
}
const createCount = listResources.filter((item) => createModes.has(item.mode)).length;
console.log(

View File

@@ -1,6 +1,6 @@
/**
* 功能:统一资源列表与详情页面的字段名称、关系、金额、状态和时间展示。
* 版本v1.8.0
* 版本v1.9.0
*/
import dayjs from 'dayjs';
import type { RecordPageMode, ResourceRow } from './resource-page-rules';
@@ -36,6 +36,8 @@ const aliases: Record<string, string> = {
delivery_basic_display_name: '配送点',
staff_account_display_name: '工作人员',
user_account_display_name: '用户',
user_service_active: '签约用户仍属当前气站',
contract_readonly: '账户权限',
creator_display_name: '创建方',
contract_display_name: '配送合同',
assigner_name: '分配人',
@@ -375,6 +377,8 @@ export function displayRawValue(key: string, value: unknown) {
if (key === 'staff_account_identity' && (value == null || value === ''))
return '暂未分配';
if (value == null || value === '') return '-';
if (key === 'contract_readonly' && value === true)
return '仅查看当前合同关联资料';
if (typeof value === 'boolean') return value ? '是' : '否';
if (key === 'status') return recordStatusLabel(Number(value));
if (key === 'contract_status') return contractStatusLabel(Number(value));

View File

@@ -1,6 +1,6 @@
/**
* 功能:定义标准资源在新建、详情和编辑页面中的字段能力与动态编辑限制。
* 版本v1.0.0
* 版本v1.1.0
*/
import type { ResourceField, ResourceUiDefinition } from './resources';
@@ -325,7 +325,14 @@ export function editBlockedReason(
context: ResourcePageContext,
) {
if (!definition.canEdit) return '当前资源不支持编辑';
if (
definition.name === 'user_account' &&
row.contract_readonly === true
)
return '该用户仅因本站合同可见,不能在当前气站编辑';
if (definition.name === 'gasorder_contract') {
if (row.user_service_active === false)
return '签约用户已不属于当前气站,只允许查看或终止合同';
if (Number(row.contract_status) !== 0)
return '只有草稿状态的合同可以编辑';
if (![0, 1].includes(Number(row.status)))

View File

@@ -1,3 +1,7 @@
/**
* 功能:封装标准资源列表、详情、新建、编辑和业务动作请求。
* 版本v1.1.0
*/
import { request, type PageResult } from './http';
/** 所有标准 CRUD 资源共享的调用方法。 */
@@ -6,7 +10,10 @@ export const resourceApi = {
const query = new URLSearchParams({ page: String(page), size: String(size), ...filters });
return request<PageResult<T>>(`${resource}?${query.toString()}`);
},
detail: <T>(resource: string, identity: string) => request<T>(`${resource}/${identity}`),
detail: <T>(resource: string, identity: string, filters: Record<string, string> = {}) => {
const query = new URLSearchParams(filters).toString();
return request<T>(`${resource}/${identity}${query ? `?${query}` : ''}`);
},
create: <T>(resource: string, data: Record<string, unknown>) => request<T>(resource, { method: 'POST', body: JSON.stringify(data) }),
update: <T>(resource: string, identity: string, data: Record<string, unknown>) => request<T>(`${resource}/${identity}`, { method: 'PUT', body: JSON.stringify(data) }),
updateStatus: (resource: string, identity: string, status: number) => request<{ updated: boolean }>(`${resource}/${identity}/status`, { method: 'PATCH', body: JSON.stringify({ status }) }),

View File

@@ -1,6 +1,6 @@
<!--
功能承载气站管理端全部标准资源的新建详情和编辑独立页面
版本v1.5.0
版本v1.7.0
-->
<template>
<div class="record-page">
@@ -75,7 +75,7 @@
</a-space>
</a-card>
<ResourceWalletSummary
v-if="definition.walletOwnerType"
v-if="definition.walletOwnerType && record.contract_readonly !== true"
:wallet="wallet"
@view="viewWallet"
/>
@@ -400,9 +400,20 @@ const canEditRecord = computed(
const visibleActions = computed(() =>
(definition.value.detailActions ?? []).filter(
(action) =>
!action.visibleFor ||
action.visibleFor.values.includes(
record.value[action.visibleFor.field] as string | number,
!(
definition.value.name === 'user_account' &&
record.value.contract_readonly === true
) &&
!(
definition.value.name === 'gasorder_contract' &&
record.value.user_service_active === false &&
action.name !== '终止合同'
) &&
(
!action.visibleFor ||
action.visibleFor.values.includes(
record.value[action.visibleFor.field] as string | number,
)
),
),
);
@@ -454,6 +465,10 @@ async function initialize() {
detail.value = await resourceApi.detail<ResourceRow>(
definition.value.resource,
identity.value,
definition.value.name === 'user_account' &&
typeof route.query.contract_identity === 'string'
? { contract_identity: route.query.contract_identity }
: {},
);
contractAttachment.load(
definition.value.name === 'gasorder_contract'
@@ -511,6 +526,7 @@ async function loadWallet() {
if (
!definition.value.walletOwnerType ||
mode.value === 'create' ||
record.value.contract_readonly === true ||
!recordIdentity.value
)
return;

View File

@@ -1,6 +1,6 @@
/**
* 功能:为资源表单和详情页加载、缓存并搜索关联资源选项。
* 版本v1.0.0
* 版本v1.1.0
*/
import { Message } from '@arco-design/web-vue';
import { onBeforeUnmount, reactive } from 'vue';
@@ -12,6 +12,7 @@ import {
import type { ResourceField } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
import { fieldRelationResource } from '@/api/resource-display';
import { relationDisplayName } from '../shared/resource-list-field-display';
import { createRelationRequestVersionGuard } from './resource-relation-linkage-policy';
export type ResourceRelationLoadOptions = {
@@ -134,6 +135,8 @@ export function useResourceRelations(
/** 补载表单或详情中已经保存的关系值,避免分页和筛选导致回显裸标识。 */
async function ensureValues(fields: ResourceField[], values: ResourceRow) {
const requests = fields.flatMap((field) => {
// 服务端已在当前业务记录范围内返回名称时,不再越权补载全局关联资源。
if (relationDisplayName(field, values)) return [];
const resource = fieldRelationResource(field, values);
if (!resource) return [];
const value = values[field.key];

View File

@@ -1,6 +1,6 @@
<!--
功能展示标准资源列表并将新建详情和编辑入口导航到独立页面
版本v2.3.0
版本v2.5.0
-->
<template>
<a-card :title="listTitle" :bordered="false">
@@ -121,7 +121,7 @@
<a-button v-if="definition.name === 'staff_account'" size="mini" @click="viewCredentials(record)">查看资质</a-button>
<a-button size="mini" @click="openRecord('detail', record)">详情</a-button>
<a-button
v-if="definition.name === 'gasorder_contract' && Number(record.contract_status) === 0"
v-if="definition.name === 'gasorder_contract' && Number(record.contract_status) === 0 && record.user_service_active !== false"
size="mini"
type="primary"
@click="bindContractProduct(record)"
@@ -427,6 +427,14 @@ function openRelation(field: ResourceField, row: ResourceRow) {
void router.push({
name: target.name,
params: { identity: identityFieldValue(field, row) },
query:
props.definition.name === 'gasorder_contract' &&
field.key === 'user_account_identity'
? {
contract_identity: String(row.identity ?? ''),
return_to: route.fullPath,
}
: { return_to: route.fullPath },
});
}

View File

@@ -1,6 +1,6 @@
/**
* 功能描述:集中处理标准资源列表字段的关系名称、唯一标识和列宽展示。
* 版本v1.1.0
* 版本v1.2.0
*/
import { fieldRelationResource, optionLabel } from '@/api/resource-display';
import type { ResourceRow } from '@/api/resource-page-rules';
@@ -106,6 +106,8 @@ export function relationListName(
row: ResourceRow,
relationOptions: Record<string, ResourceRow[]>,
) {
const serverName = relationDisplayName(field, row);
if (serverName) return serverName;
const identity = identityFieldValue(field, row);
const resource = fieldRelationResource(field, row);
const match = (relationOptions[resource] ?? []).find(
@@ -114,6 +116,14 @@ export function relationListName(
return match ? optionLabel(match) : '名称加载失败';
}
/** 读取服务端在当前业务记录权限范围内直接返回的关联名称。 */
export function relationDisplayName(field: ResourceField, row: ResourceRow) {
const key = field.key.endsWith('_identity')
? `${field.key.slice(0, -9)}_display_name`
: '';
return key ? String(row[key] ?? '').trim() : '';
}
/** 按字段类型返回标准列表列宽。 */
export function resourceListColumnWidth(
definitionName: string,

View File

@@ -1,6 +1,6 @@
/**
* 功能:统一资源列表与详情页面的字段名称、关系、金额、状态和时间展示。
* 版本v1.7.0
* 版本v1.8.0
*/
import dayjs from 'dayjs';
import type { RecordPageMode, ResourceRow } from './resource-page-rules';
@@ -36,6 +36,7 @@ const aliases: Record<string, string> = {
delivery_basic_display_name: '配送点',
staff_account_display_name: '工作人员',
user_account_display_name: '用户',
user_service_active: '签约用户仍属合同气站',
creator_display_name: '创建方',
contract_display_name: '配送合同',
assigner_name: '分配人',

View File

@@ -1,6 +1,6 @@
/**
* 功能:定义标准资源在新建、详情和编辑页面中的字段能力与动态编辑限制。
* 版本v1.0.0
* 版本v1.1.0
*/
import type { ResourceField, ResourceUiDefinition } from './resources';
@@ -326,6 +326,8 @@ export function editBlockedReason(
) {
if (!definition.canEdit) return '当前资源不支持编辑';
if (definition.name === 'gasorder_contract') {
if (row.user_service_active === false)
return '签约用户已不属于合同气站,只允许查看或终止合同';
if (Number(row.contract_status) !== 0)
return '只有草稿状态的合同可以编辑';
if (![0, 1].includes(Number(row.status)))

View File

@@ -1,6 +1,6 @@
<!--
功能承载平台总后台全部标准资源的新建详情和编辑独立页面
版本v1.3.0
版本v1.4.0
-->
<template>
<div class="record-page">
@@ -400,9 +400,16 @@ const canEditRecord = computed(
const visibleActions = computed(() =>
(definition.value.detailActions ?? []).filter(
(action) =>
!action.visibleFor ||
action.visibleFor.values.includes(
record.value[action.visibleFor.field] as string | number,
!(
definition.value.name === 'gasorder_contract' &&
record.value.user_service_active === false &&
action.name !== '终止合同'
) &&
(
!action.visibleFor ||
action.visibleFor.values.includes(
record.value[action.visibleFor.field] as string | number,
)
),
),
);

View File

@@ -1,6 +1,6 @@
/**
* 功能:为资源表单和详情页加载、缓存并搜索关联资源选项。
* 版本v1.0.0
* 版本v1.1.0
*/
import { Message } from '@arco-design/web-vue';
import { onBeforeUnmount, reactive } from 'vue';
@@ -12,6 +12,7 @@ import {
import type { ResourceField } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
import { fieldRelationResource } from '@/api/resource-display';
import { relationDisplayName } from '../shared/resource-list-field-display';
import { createRelationRequestVersionGuard } from './resource-relation-linkage-policy';
export type ResourceRelationLoadOptions = {
@@ -134,6 +135,8 @@ export function useResourceRelations(
/** 补载表单或详情中已经保存的关系值,避免分页和筛选导致回显裸标识。 */
async function ensureValues(fields: ResourceField[], values: ResourceRow) {
const requests = fields.flatMap((field) => {
// 服务端已在当前业务记录范围内返回名称时,不再重复补载关联用户。
if (relationDisplayName(field, values)) return [];
const resource = fieldRelationResource(field, values);
if (!resource) return [];
const value = values[field.key];

View File

@@ -1,6 +1,6 @@
<!--
功能展示标准资源列表并将新建详情和编辑入口导航到独立页面
版本v2.3.0
版本v2.4.0
-->
<template>
<a-card :title="listTitle" :bordered="false">
@@ -121,7 +121,7 @@
<a-button v-if="definition.name === 'staff_account'" size="mini" @click="viewCredentials(record)">查看资质</a-button>
<a-button size="mini" @click="openRecord('detail', record)">详情</a-button>
<a-button
v-if="definition.name === 'gasorder_contract' && Number(record.contract_status) === 0"
v-if="definition.name === 'gasorder_contract' && Number(record.contract_status) === 0 && record.user_service_active !== false"
size="mini"
type="primary"
@click="bindContractProduct(record)"

View File

@@ -1,6 +1,6 @@
/**
* 功能描述:集中处理标准资源列表字段的关系名称、唯一标识和列宽展示。
* 版本v1.1.0
* 版本v1.2.0
*/
import { fieldRelationResource, optionLabel } from '@/api/resource-display';
import type { ResourceRow } from '@/api/resource-page-rules';
@@ -106,6 +106,8 @@ export function relationListName(
row: ResourceRow,
relationOptions: Record<string, ResourceRow[]>,
) {
const serverName = relationDisplayName(field, row);
if (serverName) return serverName;
const identity = identityFieldValue(field, row);
const resource = fieldRelationResource(field, row);
const match = (relationOptions[resource] ?? []).find(
@@ -114,6 +116,14 @@ export function relationListName(
return match ? optionLabel(match) : '名称加载失败';
}
/** 读取服务端在当前业务记录权限范围内直接返回的关联名称。 */
export function relationDisplayName(field: ResourceField, row: ResourceRow) {
const key = field.key.endsWith('_identity')
? `${field.key.slice(0, -9)}_display_name`
: '';
return key ? String(row[key] ?? '').trim() : '';
}
/** 按字段类型返回标准列表列宽。 */
export function resourceListColumnWidth(
definitionName: string,