fix: close platform admin final audit findings

This commit is contained in:
2026-07-27 12:02:08 +08:00
parent 0ba4136e1a
commit 34e8b092b3
70 changed files with 1873 additions and 348 deletions

View File

@@ -54,7 +54,7 @@ func CreateGasAccount(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, resourceResponse(account))
respondCreatedResource(ctx, 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, resourceResponse(account))
respondCreatedResource(ctx, account)
}
func UpdateDeliveryAccount(ctx *gin.Context) {

View File

@@ -37,7 +37,7 @@ func CreateDeliveryBasic(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, resourceResponse(delivery))
respondCreatedResource(ctx, delivery)
}
// UpdateDeliveryBasic 更新配送点基础资料。

View File

@@ -26,7 +26,7 @@ func CreateGasBasic(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, request)
respondCreatedResource(ctx, request)
}
// UpdateGasBasic 更新可燃气体站基础资料。

View File

@@ -3,6 +3,8 @@ package platform
import (
"errors"
"reflect"
"strings"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
@@ -38,7 +40,8 @@ func listPage[T any](ctx *gin.Context) {
page, size := pageSize(ctx)
var list []T
var total int64
databaseQuery := impl.DBService.Model(new(T))
model := new(T)
databaseQuery := applyKeywordFilter(ctx, impl.DBService.Model(model), model)
if err := databaseQuery.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
@@ -55,6 +58,58 @@ func listPage[T any](ctx *gin.Context) {
infra.Response.Success(ctx, gin.H{"total": total, "list": response})
}
var keywordExcludedColumns = map[string]bool{
"identity": true, "status": true, "password_hash": true,
"longitude": true, "latitude": true, "payload": true,
"before_data": true, "after_data": true,
}
func applyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB {
keyword := strings.ToLower(strings.TrimSpace(ctx.Query("keyword")))
if keyword == "" {
return query
}
columns := keywordColumns(model)
if len(columns) == 0 {
return query
}
conditions := make([]string, 0, len(columns))
arguments := make([]any, 0, len(columns))
for _, column := range columns {
conditions = append(conditions, `LOWER("`+column+`") LIKE ?`)
arguments = append(arguments, "%"+keyword+"%")
}
return query.Where("("+strings.Join(conditions, " OR ")+")", arguments...)
}
func keywordColumns(model any) []string {
modelType := reflect.TypeOf(model)
for modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
columns := make([]string, 0)
for index := 0; index < modelType.NumField(); index++ {
field := modelType.Field(index)
if field.Anonymous || field.Type.Kind() != reflect.String {
continue
}
column := gormColumn(field.Tag.Get("gorm"))
if column != "" && !keywordExcludedColumns[column] {
columns = append(columns, column)
}
}
return columns
}
func gormColumn(tag string) string {
for _, part := range strings.Split(tag, ";") {
if strings.HasPrefix(part, "column:") {
return strings.TrimPrefix(part, "column:")
}
}
return ""
}
func getByIdentity[T any](ctx *gin.Context) {
var data T
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&data).Error; err != nil {

View File

@@ -77,7 +77,7 @@ func ExpectedResources() []ResourceContract {
resourceContract("device", "dev_smart_cylinder_valve", Writable, "list"), resourceContract("device", "dev_device_binding", Writable, "list"), resourceContract("device", "dev_telemetry", ReadOnly, "list"),
resourceContract("safety", "saf_rule", Writable, "list"), resourceContract("safety", "saf_event", Writable, "list"), resourceContract("safety", "saf_inspection", Writable, "list"), resourceContract("safety", "saf_event_disposal", AppendOnly, "list"),
resourceContract("ec", "ec_category", Writable, "list"), resourceContract("ec", "ec_product", Writable, "list"), resourceContract("ec", "ec_product_attribute", Writable, "list"), resourceContract("ec", "ec_product_image", Writable, "list"), resourceContract("ec", "ec_cart", Writable, "list"), resourceContract("ec", "ec_order", Writable, "list"), resourceContract("ec", "ec_order_item", Writable, "list"), resourceContract("ec", "ec_review", Writable, "list"),
resourceContract("delivery", "delivery_task", Writable, "list"), resourceContract("delivery", "delivery_track", Writable, "list"), resourceContract("delivery", "delivery_track_point", Writable, "list"),
resourceContract("delivery", "delivery_task", Writable, "list"), resourceContract("delivery", "delivery_track", Writable, "list"), resourceContract("delivery", "delivery_track_point", ReadOnly, "list"),
resourceContract("finance", "fin_payment", Writable, "list"), resourceContract("finance", "fin_settlement", Writable, "list"), resourceContract("finance", "fin_reconciliation", Writable, "list"),
resourceContract("content", "cnt_content", Writable, "list"), resourceContract("notification", "ntf_template", Writable, "list"), resourceContract("customer_service", "cs_ticket", Writable, "list"),
resourceContract("platform", "platfrom_account", Writable, "list"), resourceContract("platform", "platform_role", Writable, "list"), resourceContract("platform", "platform_menu", Writable, "tree"),

View File

@@ -27,6 +27,7 @@ func TestExpectedResources(t *testing.T) {
assertContract(t, ExpectedResources(), "safety", "saf_event", Writable, "list")
assertContract(t, ExpectedResources(), "ec", "ec_order_item", Writable, "list")
assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list")
assertContract(t, ExpectedResources(), "delivery", "delivery_track_point", ReadOnly, "list")
}
func TestResourceDefinitionAllowsOnlySupportedMethods(t *testing.T) {
@@ -238,10 +239,37 @@ func TestCreateGasAccountResolvesGasBasicIdentityBeforePersisting(t *testing.T)
mock.ExpectQuery(`INSERT INTO "gas_account"`).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1)))
mock.ExpectCommit()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1)`)).
WithArgs(uint64(8)).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(8), "gas-a"))
ctx, recorder := updateContext(http.MethodPost, "/gas/gas_account", "", []byte(`{"username":"operator","password":"password-123","gas_basic_identity":"gas-a"}`))
CreateGasAccount(ctx)
assertResponseCode(t, recorder, 0)
body := recorder.Body.String()
if !strings.Contains(body, `"identity"`) || !strings.Contains(body, `"gas_basic_identity":"gas-a"`) {
t.Fatalf("create response omitted public identities: %s", body)
}
if strings.Contains(body, `"gas_basic_id"`) || strings.Contains(body, `"password_hash"`) {
t.Fatalf("create response exposed internal or sensitive fields: %s", body)
}
assertMockExpectations(t, mock)
}
func TestListGasAccountAppliesKeywordToCountAndRows(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
keywordWhere := ` WHERE (LOWER("username") LIKE $1 OR LOWER("display_name") LIKE $2 OR LOWER("role_code") LIKE $3)`
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`+keywordWhere)).
WithArgs("%operator%", "%operator%", "%operator%").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "gas_account"`+keywordWhere+` ORDER BY created_at desc LIMIT $4`)).
WithArgs("%operator%", "%operator%", "%operator%", 20).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "gas_basic_id", "username", "display_name", "password_hash", "role_code"}))
ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account?keyword=Operator", "", nil)
ListGasAccount(ctx)
assertResponseCode(t, recorder, 0)
assertMockExpectations(t, mock)
}
@@ -276,6 +304,30 @@ func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) {
}
}
func TestCreatedResourceResponseMasksSensitiveFieldsAndKeepsIdentity(t *testing.T) {
response := maskCreatedSensitiveFields(map[string]any{
"identity": "user-a",
"phone": "13800138000",
"real_name": "张三",
"credential_no": "CERT-123456",
"longitude": "120.123456",
"latitude": "30.456789",
})
encoded, err := json.Marshal(response)
if err != nil {
t.Fatal(err)
}
body := string(encoded)
if !strings.Contains(body, `"identity":"user-a"`) || !strings.Contains(body, `"phone_masked":"138****8000"`) {
t.Fatalf("created response omitted identity or masked phone: %s", body)
}
for _, forbidden := range []string{`"phone":`, `"real_name":`, `"credential_no":`, `"longitude":`, `"latitude":`, "张三", "CERT-123456", "120.123456", "30.456789"} {
if strings.Contains(body, forbidden) {
t.Fatalf("created response exposed sensitive field %s: %s", forbidden, body)
}
}
}
func TestListSafetyEventDisposalsReturnsOnlyTheRequestedEventHistory(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "saf_event_disposal" WHERE saf_event_identity = $1`)).
@@ -392,6 +444,55 @@ func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *test
assertMockExpectations(t, mock)
}
func TestListDeliveryTrackPointsMasksCoordinatesWithoutPreciseLocationScope(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
now := time.Now().UTC()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "delivery_track_point"`)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track_point" ORDER BY created_at desc LIMIT $1`)).
WithArgs(20).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_track_id", "point_type", "occurred_at", "longitude", "latitude"}).
AddRow(uint64(9), "point-a", now, now, "enabled", 1, uint64(7), "arrival", now, "120.123456", "30.456789"))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "delivery_track" WHERE id IN ($1)`)).
WithArgs(uint64(7)).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "track-a"))
ctx, recorder := updateContext(http.MethodGet, "/delivery/delivery_track_point", "", nil)
listResource(ctx, &models.DeliveryTrackPoint{})
assertResponseCode(t, recorder, 0)
body := recorder.Body.String()
if strings.Contains(body, "120.123456") || strings.Contains(body, "30.456789") {
t.Fatalf("track-point list exposed precise coordinates without scope: %s", body)
}
if !strings.Contains(body, `"delivery_track_identity":"track-a"`) {
t.Fatalf("track-point list omitted its public relation identity: %s", body)
}
assertMockExpectations(t, mock)
}
func TestGetDeliveryTrackPointReturnsCoordinatesWithPreciseLocationScope(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
now := time.Now().UTC()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track_point" WHERE identity = $1 ORDER BY "delivery_track_point"."id" LIMIT $2`)).
WithArgs("point-a", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_track_id", "point_type", "occurred_at", "longitude", "latitude"}).
AddRow(uint64(9), "point-a", now, now, "enabled", 1, uint64(7), "arrival", now, "120.123456", "30.456789"))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "delivery_track" WHERE id IN ($1)`)).
WithArgs(uint64(7)).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "track-a"))
ctx, recorder := updateContext(http.MethodGet, "/delivery/delivery_track_point/point-a", "point-a", nil)
ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}})
getResource(ctx, &models.DeliveryTrackPoint{})
assertResponseCode(t, recorder, 0)
if !strings.Contains(recorder.Body.String(), "120.123456") || !strings.Contains(recorder.Body.String(), "30.456789") {
t.Fatalf("authorized track-point detail omitted precise coordinates: %s", recorder.Body.String())
}
assertMockExpectations(t, mock)
}
func TestDisposeSafetyEventUpdatesEventAndAppendsOperatorActionTransactionally(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
now := time.Now().UTC()

View File

@@ -35,7 +35,7 @@ func CreatePlatformRole(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, request)
respondCreatedResource(ctx, request)
}
// UpdatePlatformRole 更新非内置平台角色。
@@ -125,7 +125,7 @@ func CreatePlatformMenu(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, menu)
respondCreatedResource(ctx, menu)
}
func UpdatePlatformMenu(ctx *gin.Context) {
@@ -243,8 +243,14 @@ func ListPlatformMenu(ctx *gin.Context) {
// ListPlatfromAccount 查询平台账号列表,手机号在展示层脱敏。
func ListPlatfromAccount(ctx *gin.Context) {
page, size := pageSize(ctx)
list, total, err := models.ListPlatfromAccount(page, size)
if err != nil {
var list []models.PlatfromAccount
var total int64
query := applyKeywordFilter(ctx, impl.DBService.Model(&models.PlatfromAccount{}), &models.PlatfromAccount{})
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
if err := query.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}

View File

@@ -35,7 +35,7 @@ func CreateStaffCredential(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, resourceResponse(credential))
respondCreatedResource(ctx, 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, resourceResponse(address))
respondCreatedResource(ctx, 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, resourceResponse(relation))
respondCreatedResource(ctx, relation)
}
func UpdateUserServiceRelation(ctx *gin.Context) {
var request userServiceRelationRequest

View File

@@ -54,7 +54,7 @@ func CreateStaff(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, staff)
respondCreatedResource(ctx, staff)
}
// UpdateStaff 更新服务人员档案。

View File

@@ -94,7 +94,7 @@ func listResource(ctx *gin.Context, model any) {
page, size := pageSize(ctx)
list := reflect.New(reflect.SliceOf(reflect.TypeOf(model).Elem()))
var total int64
query := impl.DBService.Model(model)
query := applyKeywordFilter(ctx, impl.DBService.Model(model), model)
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
@@ -108,7 +108,7 @@ func listResource(ctx *gin.Context, model any) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "list": response})
infra.Response.Success(ctx, gin.H{"total": total, "list": protectPreciseLocation(ctx, model, response)})
}
func getResource(ctx *gin.Context, model any) {
@@ -122,7 +122,7 @@ func getResource(ctx *gin.Context, model any) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, response)
infra.Response.Success(ctx, protectPreciseLocation(ctx, model, response))
}
func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
@@ -146,7 +146,88 @@ func createResource(ctx *gin.Context, model any, allowedFields []string, relatio
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, resourceResponse(data.Interface()))
respondCreatedResource(ctx, data.Interface())
}
func respondCreatedResource(ctx *gin.Context, value any) {
response, err := publicResourceResponse(value)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, maskCreatedSensitiveFields(response))
}
func maskCreatedSensitiveFields(value any) any {
switch data := value.(type) {
case map[string]any:
if phone, ok := data["phone"].(string); ok {
data["phone_masked"] = maskPhone(phone)
delete(data, "phone")
}
if realName, ok := data["real_name"].(string); ok {
data["real_name_masked"] = maskSecret(realName)
delete(data, "real_name")
}
if credentialNo, ok := data["credential_no"].(string); ok {
data["credential_no_masked"] = maskSecret(credentialNo)
delete(data, "credential_no")
}
delete(data, "longitude")
delete(data, "latitude")
for key, item := range data {
data[key] = maskCreatedSensitiveFields(item)
}
case []any:
for index := range data {
data[index] = maskCreatedSensitiveFields(data[index])
}
}
return value
}
func maskSecret(value string) string {
characters := []rune(value)
if len(characters) <= 1 {
return "*"
}
visible := 1
if len(characters) > 4 {
visible = 4
}
return strings.Repeat("*", len(characters)-visible) + string(characters[len(characters)-visible:])
}
func protectPreciseLocation(ctx *gin.Context, model, value any) any {
if reflect.TypeOf(model) != reflect.TypeOf(&models.DeliveryTrackPoint{}) || hasPreciseLocationScope(ctx) {
return value
}
clearCoordinateFields(value)
return value
}
func hasPreciseLocationScope(ctx *gin.Context) bool {
claims, err := middleware.ParseAuth(ctx)
return err == nil && claims.Extend["location_scope"] == "precise"
}
func clearCoordinateFields(value any) {
switch data := value.(type) {
case map[string]any:
if _, ok := data["longitude"]; ok {
data["longitude"] = ""
}
if _, ok := data["latitude"]; ok {
data["latitude"] = ""
}
for _, item := range data {
clearCoordinateFields(item)
}
case []any:
for _, item := range data {
clearCoordinateFields(item)
}
}
}
func updateResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
@@ -521,11 +602,7 @@ func GetDeliveryTrack(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
precise := false
if claims, err := middleware.ParseAuth(ctx); err == nil {
precise = claims.Extend["location_scope"] == "precise"
}
if !precise {
if !hasPreciseLocationScope(ctx) {
for index := range points {
points[index].Longitude = ""
points[index].Latitude = ""

View File

@@ -38,7 +38,7 @@ func CreateUser(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, user)
respondCreatedResource(ctx, user)
}
// UpdateUser 更新业主客户档案。