fix: audit platform contracts and relation identities

This commit is contained in:
2026-07-27 10:25:04 +08:00
parent 2e62238416
commit 7ebe25fe34
9 changed files with 225 additions and 1251 deletions

View File

@@ -54,7 +54,7 @@ func CreateGasAccount(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, account)
infra.Response.Success(ctx, resourceResponse(account))
}
func UpdateGasAccount(ctx *gin.Context) {
@@ -95,7 +95,7 @@ func CreateDeliveryAccount(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, account)
infra.Response.Success(ctx, resourceResponse(account))
}
func UpdateDeliveryAccount(ctx *gin.Context) {

View File

@@ -47,7 +47,7 @@ func listPage[T any](ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "list": list})
infra.Response.Success(ctx, gin.H{"total": total, "list": publicResourceResponse(list)})
}
func getByIdentity[T any](ctx *gin.Context) {
@@ -56,7 +56,7 @@ func getByIdentity[T any](ctx *gin.Context) {
respondRecordError(ctx, err)
return
}
infra.Response.Success(ctx, data)
infra.Response.Success(ctx, publicResourceResponse(data))
}
func updateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) {

View File

@@ -261,6 +261,29 @@ func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) {
}
}
func TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "gas_account" ORDER BY created_at desc LIMIT $1`)).
WithArgs(20).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "gas_basic_id", "username", "display_name", "password_hash", "role_code"}).
AddRow(uint64(9), "account-a", nil, nil, "enabled", 1, uint64(7), "operator", "Operator", "hash", "admin"))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "identity" FROM "gas_basic" WHERE id = $1 ORDER BY "gas_basic"."id" LIMIT $2`)).
WithArgs(uint64(7), 1).
WillReturnRows(sqlmock.NewRows([]string{"identity"}).AddRow("gas-a"))
ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account", "", nil)
ListGasAccount(ctx)
assertResponseCode(t, recorder, 0)
body := recorder.Body.String()
if !strings.Contains(body, `"gas_basic_identity":"gas-a"`) || strings.Contains(body, `"gas_basic_id"`) || strings.Contains(body, `"id":`) {
t.Fatalf("account list did not return the public relation shape: %s", body)
}
assertMockExpectations(t, mock)
}
func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
now := time.Now().UTC()

View File

@@ -35,7 +35,7 @@ func CreateStaffCredential(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, credential)
infra.Response.Success(ctx, resourceResponse(credential))
}
func UpdateStaffCredential(ctx *gin.Context) {
var request staffCredentialRequest
@@ -77,7 +77,7 @@ func CreateUserAddress(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, address)
infra.Response.Success(ctx, resourceResponse(address))
}
func UpdateUserAddress(ctx *gin.Context) {
var request userAddressRequest
@@ -133,7 +133,7 @@ func CreateUserServiceRelation(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, relation)
infra.Response.Success(ctx, resourceResponse(relation))
}
func UpdateUserServiceRelation(ctx *gin.Context) {
var request userServiceRelationRequest

View File

@@ -102,7 +102,7 @@ func listResource(ctx *gin.Context, model any) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "list": resourceResponse(list.Elem().Interface())})
infra.Response.Success(ctx, gin.H{"total": total, "list": publicResourceResponse(list.Elem().Interface())})
}
func getResource(ctx *gin.Context, model any) {
@@ -111,7 +111,7 @@ func getResource(ctx *gin.Context, model any) {
respondRecordError(ctx, err)
return
}
infra.Response.Success(ctx, resourceResponse(data.Interface()))
infra.Response.Success(ctx, publicResourceResponse(data.Interface()))
}
func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
@@ -230,6 +230,113 @@ func resourceResponse(value any) any {
return stripInternalIDs(decoded)
}
// publicResourceResponse additionally resolves persisted relation keys into
// their public identities. It is used by list/detail endpoints so an edit form
// can round-trip the relation without ever receiving a surrogate database ID.
func publicResourceResponse(value any) any {
encoded, err := json.Marshal(value)
if err != nil {
return value
}
var decoded any
if err := json.Unmarshal(encoded, &decoded); err != nil {
return value
}
return projectRelationIdentities(decoded)
}
var relationIdentityModels = map[string]any{
"gas_basic_id": &models.GasBasic{},
"gas_station_id": &models.GasBasic{},
"delivery_basic_id": &models.DeliveryBasic{},
"delivery_point_id": &models.DeliveryBasic{},
"user_account_id": &models.UserAccount{},
"staff_account_id": &models.StaffAccount{},
"smart_cylinder_valve_id": &models.DevSmartCylinderValve{},
"ec_category_id": &models.EcCategory{},
"ec_product_id": &models.EcProduct{},
"ec_order_id": &models.EcOrder{},
"delivery_task_id": &models.DeliveryTask{},
"delivery_track_id": &models.DeliveryTrack{},
"platform_role_id": &models.PlatformRole{},
"platform_menu_id": &models.PlatformMenu{},
"report_id": &models.Report{},
"wallet_id": &models.Wallet{},
}
var relationIdentityKeys = map[string]string{
"gas_station_id": "gas_basic_identity",
"delivery_point_id": "delivery_basic_identity",
}
func projectRelationIdentities(value any) any {
switch data := value.(type) {
case map[string]any:
for key, item := range data {
if key == "id" {
delete(data, key)
continue
}
if strings.HasSuffix(key, "_id") {
identityKey := strings.TrimSuffix(key, "_id") + "_identity"
if alias := relationIdentityKeys[key]; alias != "" {
identityKey = alias
}
model := relationIdentityModels[key]
if key == "subject_id" {
model = settlementSubjectModel(data["subject_type"])
identityKey = "subject_identity"
}
if model != nil {
data[identityKey] = relationIdentity(item, model)
}
delete(data, key)
continue
}
data[key] = projectRelationIdentities(item)
}
case []any:
for index := range data {
data[index] = projectRelationIdentities(data[index])
}
}
return value
}
func relationIdentity(value any, model any) string {
var id uint64
switch raw := value.(type) {
case float64:
id = uint64(raw)
case uint64:
id = raw
case int:
id = uint64(raw)
}
if id == 0 {
return ""
}
var related struct{ Identity string }
if err := impl.DBService.Model(model).Select("identity").Where("id = ?", id).First(&related).Error; err != nil {
return ""
}
return related.Identity
}
func settlementSubjectModel(value any) any {
subjectType, _ := value.(string)
switch subjectType {
case "gas", "gas_basic":
return &models.GasBasic{}
case "delivery", "delivery_basic":
return &models.DeliveryBasic{}
case "staff", "staff_account":
return &models.StaffAccount{}
default:
return nil
}
}
func stripInternalIDs(value any) any {
switch data := value.(type) {
case map[string]any:
@@ -311,7 +418,7 @@ func GetEcOrder(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, resourceResponse(gin.H{"order": order, "items": items}))
infra.Response.Success(ctx, publicResourceResponse(gin.H{"order": order, "items": items}))
}
// GetDeliveryTrack returns time-ordered points. Precise coordinates are only
@@ -337,7 +444,7 @@ func GetDeliveryTrack(ctx *gin.Context) {
points[index].Latitude = ""
}
}
infra.Response.Success(ctx, resourceResponse(gin.H{"track": track, "points": points}))
infra.Response.Success(ctx, publicResourceResponse(gin.H{"track": track, "points": points}))
}
type ecCategoryView struct {