diff --git a/backend/api/cmd/cli/main.go b/backend/api/cmd/cli/main.go index 15cab45..eeb529a 100644 --- a/backend/api/cmd/cli/main.go +++ b/backend/api/cmd/cli/main.go @@ -1,4 +1,4 @@ -// 平台 API 的版本命令行工具。 +// 平台 API 命令行工具。 package main import ( @@ -8,12 +8,24 @@ import ( func main() { if len(os.Args) < 2 { - fmt.Println("usage: platform-cli ") - return - } - if os.Args[1] != "version" { - fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) + printUsage() + os.Exit(1) + } + switch os.Args[1] { + case "version": + fmt.Println("platform-cli 0.1.0") + case "resource-contract": + if err := writeResourceContract(os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) + printUsage() os.Exit(1) } - fmt.Println("platform-cli 0.1.0") +} + +func printUsage() { + fmt.Fprintln(os.Stderr, "usage: platform-cli ") } diff --git a/backend/api/cmd/resource-contract/main.go b/backend/api/cmd/cli/resource_contract.go similarity index 76% rename from backend/api/cmd/resource-contract/main.go rename to backend/api/cmd/cli/resource_contract.go index 8c07334..a3b733f 100644 --- a/backend/api/cmd/resource-contract/main.go +++ b/backend/api/cmd/cli/resource_contract.go @@ -2,7 +2,7 @@ package main import ( "encoding/json" - "os" + "io" "strings" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform" @@ -14,6 +14,7 @@ type route struct { Method string `json:"method"` Path string `json:"path"` } + type contract struct { Domain string `json:"domain"` Name string `json:"name"` @@ -21,12 +22,13 @@ type contract struct { PageKind string `json:"pageKind"` Mode string `json:"mode"` } + type manifest struct { Resources []contract `json:"resources"` Routes []route `json:"routes"` } -func main() { +func writeResourceContract(output io.Writer) error { gin.SetMode(gin.ReleaseMode) engine := gin.New() routers.RegisterPlatform("heqi", engine) @@ -37,9 +39,10 @@ func main() { expected := platform.ExpectedResources() contracts := make([]contract, 0, len(expected)) for _, item := range expected { - contracts = append(contracts, contract{Domain: item.Domain, Name: item.Name, Path: item.Path, PageKind: item.PageKind, Mode: string(item.Mode)}) - } - if err := json.NewEncoder(os.Stdout).Encode(manifest{Resources: contracts, Routes: routes}); err != nil { - panic(err) + contracts = append(contracts, contract{ + Domain: item.Domain, Name: item.Name, Path: item.Path, + PageKind: item.PageKind, Mode: string(item.Mode), + }) } + return json.NewEncoder(output).Encode(manifest{Resources: contracts, Routes: routes}) } diff --git a/backend/api/internal/logic/platform/platform.go b/backend/api/internal/logic/common/base.go similarity index 76% rename from backend/api/internal/logic/platform/platform.go rename to backend/api/internal/logic/common/base.go index ba67b85..e7f3f07 100644 --- a/backend/api/internal/logic/platform/platform.go +++ b/backend/api/internal/logic/common/base.go @@ -1,5 +1,5 @@ // Package platform 提供平台总后台的同步 HTTP 业务逻辑。 -package platform +package common import ( "errors" @@ -15,6 +15,21 @@ import ( "gorm.io/gorm" ) +// FilterFields keeps only explicitly allowed persistence fields. +func FilterFields(values map[string]any, allowedFields []string) gin.H { + allowed := make(map[string]struct{}, len(allowedFields)) + for _, field := range allowedFields { + allowed[field] = struct{}{} + } + filtered := make(gin.H, len(allowed)) + for field, value := range values { + if _, ok := allowed[field]; ok { + filtered[field] = value + } + } + return filtered +} + // UpdateRecordStatus 更新主表状态,停用和归档均保留历史记录。 func UpdateRecordStatus(ctx *gin.Context, model any) { var request struct { @@ -24,24 +39,24 @@ func UpdateRecordStatus(ctx *gin.Context, model any) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, model, gin.H{"status": request.Status}, []string{"status"}) + UpdateAllowedByIdentity(ctx, model, gin.H{"status": request.Status}, []string{"status"}) } // ArchiveRecord 通过 archived 状态实现逻辑删除,不物理删除主数据。 func ArchiveRecord(ctx *gin.Context, model any) { - updateAllowedByIdentity(ctx, model, archiveValues(), []string{"status"}) + UpdateAllowedByIdentity(ctx, model, gin.H{"status": "archived"}, []string{"status"}) } -func newEntity(status string) models.Entity { +func NewEntity(status string) models.Entity { return models.Entity{Identity: models.NewIdentity(), Status: status, Version: 1} } -func listPage[T any](ctx *gin.Context) { - page, size := pageSize(ctx) +func ListPage[T any](ctx *gin.Context) { + page, size := PageSize(ctx) var list []T var total int64 model := new(T) - databaseQuery := applyKeywordFilter(ctx, impl.DBService.Model(model), model) + databaseQuery := ApplyKeywordFilter(ctx, impl.DBService.Model(model), model) if err := databaseQuery.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return @@ -50,12 +65,12 @@ func listPage[T any](ctx *gin.Context) { infra.Response.Error(ctx, err) return } - response, err := publicResourceResponse(list) + response, err := PublicResourceResponse(list) if err != nil { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"total": total, "list": protectPreciseLocation(ctx, model, response)}) + infra.Response.Success(ctx, gin.H{"total": total, "list": ProtectPreciseLocation(ctx, model, response)}) } var keywordSafeColumns = map[string]bool{ @@ -74,9 +89,11 @@ var keywordSafeColumns = map[string]bool{ "record_no": true, "request_no": true, "refund_no": true, "cash_no": true, "trade_no": true, "trade_type": true, "pay_channel": true, "payment_type": true, + "contract_no": true, "creator_type": true, "from_status": true, + "to_status": true, "confirm_type": true, "product_type_name": true, } -func applyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB { +func ApplyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB { keyword := strings.ToLower(strings.TrimSpace(ctx.Query("keyword"))) if keyword == "" { return query @@ -131,22 +148,22 @@ func gormColumn(tag string) string { return "" } -func getByIdentity[T any](ctx *gin.Context) { +func GetByIdentity[T any](ctx *gin.Context) { var data T if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&data).Error; err != nil { - respondRecordError(ctx, err) + RespondRecordError(ctx, err) return } - response, err := publicResourceResponse(data) + response, err := PublicResourceResponse(data) if err != nil { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, protectPreciseLocation(ctx, new(T), response)) + infra.Response.Success(ctx, ProtectPreciseLocation(ctx, new(T), response)) } -func updateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) { - values = filterFields(values, allowedFields) +func UpdateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) { + values = FilterFields(values, allowedFields) if len(values) == 0 { infra.Response.Success(ctx, gin.H{"updated": false}) return @@ -164,7 +181,7 @@ func updateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, infra.Response.Success(ctx, gin.H{"updated": true}) } -func updateByIdentity(ctx *gin.Context, model any, values map[string]any) { +func UpdateByIdentity(ctx *gin.Context, model any, values map[string]any) { result := impl.DBService.Model(model).Where("identity = ?", ctx.Param("identity")).Updates(values) if result.Error != nil { infra.Response.Error(ctx, result.Error) @@ -177,7 +194,7 @@ func updateByIdentity(ctx *gin.Context, model any, values map[string]any) { infra.Response.Success(ctx, gin.H{"updated": true}) } -func respondRecordError(ctx *gin.Context, err error) { +func RespondRecordError(ctx *gin.Context, err error) { if errors.Is(err, gorm.ErrRecordNotFound) { infra.Response.Error(ctx, errcode.ErrRecordNotFound) return @@ -185,7 +202,7 @@ func respondRecordError(ctx *gin.Context, err error) { infra.Response.Error(ctx, err) } -func pageSize(ctx *gin.Context) (int, int) { +func PageSize(ctx *gin.Context) (int, int) { page := utils.String2Int(ctx.DefaultQuery("page", "1")) size := utils.String2Int(ctx.DefaultQuery("size", "20")) if page < 1 { @@ -197,7 +214,7 @@ func pageSize(ctx *gin.Context) (int, int) { return page, size } -func maskPhone(phone string) string { +func MaskPhone(phone string) string { if len(phone) < 7 { return "***" } diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/common/resource.go similarity index 57% rename from backend/api/internal/logic/platform/task4_resources.go rename to backend/api/internal/logic/common/resource.go index 67bea30..a7e7303 100644 --- a/backend/api/internal/logic/platform/task4_resources.go +++ b/backend/api/internal/logic/common/resource.go @@ -1,10 +1,8 @@ -package platform +package common import ( - "bytes" "encoding/json" "errors" - "io" "reflect" "sort" "strings" @@ -29,71 +27,17 @@ type ResourceRelation struct { // ResourceHandlers supplies the common identity-based CRUD boundary used by // platform resources whose writable fields are explicitly declared by routes. func ResourceHandlers(model any, createFields, updateFields []string, relations ...ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { - return func(ctx *gin.Context) { listResource(ctx, model) }, + return func(ctx *gin.Context) { ListResource(ctx, model) }, func(ctx *gin.Context) { createResource(ctx, model, createFields, relations) }, - func(ctx *gin.Context) { getResource(ctx, model) }, + func(ctx *gin.Context) { GetResource(ctx, model) }, func(ctx *gin.Context) { updateResource(ctx, model, updateFields, relations) } } -// FinSettlementHandlers accepts a public subject_identity and derives the -// polymorphic storage key from its declared subject_type. -func FinSettlementHandlers() (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { - fields := []string{"settlement_no", "subject_type", "subject_id", "period_start", "period_end"} - return func(ctx *gin.Context) { listResource(ctx, &models.FinSettlement{}) }, - func(ctx *gin.Context) { - if err := rewriteSettlementSubject(ctx); err != nil { - infra.Response.Error(ctx, errcode.ErrInvalidArgument) - return - } - createResource(ctx, &models.FinSettlement{}, fields, nil) - }, - func(ctx *gin.Context) { getResource(ctx, &models.FinSettlement{}) }, - func(ctx *gin.Context) { - if err := rewriteSettlementSubject(ctx); err != nil { - infra.Response.Error(ctx, errcode.ErrInvalidArgument) - return - } - updateResource(ctx, &models.FinSettlement{}, fields, nil) - } -} - -func rewriteSettlementSubject(ctx *gin.Context) error { - var input map[string]any - if err := ctx.ShouldBindJSON(&input); err != nil { - return err - } - subjectType, _ := input["subject_type"].(string) - identity, _ := input["subject_identity"].(string) - var model any - switch subjectType { - case "gas", "gas_basic": - model = &models.GasBasic{} - case "delivery", "delivery_basic": - model = &models.DeliveryBasic{} - case "staff", "staff_account": - model = &models.StaffAccount{} - default: - return errors.New("invalid settlement subject") - } - id, err := resolveIdentityID(model, identity, true) - if err != nil { - return err - } - delete(input, "subject_identity") - input["subject_id"] = id - encoded, err := json.Marshal(input) - if err != nil { - return err - } - ctx.Request.Body = io.NopCloser(bytes.NewReader(encoded)) - return nil -} - -func listResource(ctx *gin.Context, model any) { - page, size := pageSize(ctx) +func ListResource(ctx *gin.Context, model any) { + page, size := PageSize(ctx) list := reflect.New(reflect.SliceOf(reflect.TypeOf(model).Elem())) var total int64 - query := applyKeywordFilter(ctx, impl.DBService.Model(model), model) + query := ApplyKeywordFilter(ctx, impl.DBService.Model(model), model) if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return @@ -102,30 +46,30 @@ func listResource(ctx *gin.Context, model any) { infra.Response.Error(ctx, err) return } - response, err := publicResourceResponse(list.Elem().Interface()) + response, err := PublicResourceResponse(list.Elem().Interface()) if err != nil { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"total": total, "list": protectPreciseLocation(ctx, model, response)}) + infra.Response.Success(ctx, gin.H{"total": total, "list": ProtectPreciseLocation(ctx, model, response)}) } -func getResource(ctx *gin.Context, model any) { +func GetResource(ctx *gin.Context, model any) { data := reflect.New(reflect.TypeOf(model).Elem()) if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(data.Interface()).Error; err != nil { - respondRecordError(ctx, err) + RespondRecordError(ctx, err) return } - response, err := publicResourceResponse(data.Interface()) + response, err := PublicResourceResponse(data.Interface()) if err != nil { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, protectPreciseLocation(ctx, model, response)) + infra.Response.Success(ctx, ProtectPreciseLocation(ctx, model, response)) } func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) { - values, err := prepareResourceValues(ctx, model, allowedFields, relations) + values, err := PrepareResourceValues(ctx, model, allowedFields, relations) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -140,16 +84,16 @@ func createResource(ctx *gin.Context, model any, allowedFields []string, relatio infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - data.Elem().FieldByName("Entity").Set(reflect.ValueOf(newEntity("draft"))) + data.Elem().FieldByName("Entity").Set(reflect.ValueOf(NewEntity("draft"))) if err := impl.DBService.Create(data.Interface()).Error; err != nil { infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, data.Interface()) + RespondCreatedResource(ctx, data.Interface()) } -func respondCreatedResource(ctx *gin.Context, value any) { - response, err := publicResourceResponse(value) +func RespondCreatedResource(ctx *gin.Context, value any) { + response, err := PublicResourceResponse(value) if err != nil { infra.Response.Error(ctx, err) return @@ -186,11 +130,11 @@ func isCreatedResponseField(key string) bool { } } -func protectPreciseLocation(ctx *gin.Context, model, value any) any { +func ProtectPreciseLocation(ctx *gin.Context, model, value any) any { maskPersonalName := reflect.TypeOf(model) == reflect.TypeOf(&models.UserAccount{}) || reflect.TypeOf(model) == reflect.TypeOf(&models.StaffAccount{}) maskDisplayName := reflect.TypeOf(model) == reflect.TypeOf(&models.PlatfromAccount{}) - protectPublicFields(value, maskPersonalName, maskDisplayName, hasPreciseLocationScope(ctx)) + ProtectPublicFields(value, maskPersonalName, maskDisplayName, HasPreciseLocationScope(ctx)) return value } @@ -200,31 +144,44 @@ var sensitiveResponseFields = map[string]bool{ "attachment_uri": true, "attachment_url": true, "certificate_uri": true, "certificate_url": true, "credential_uri": true, "credential_url": true, + "proof_uri": true, } -func protectPublicFields(value any, maskPersonalName, maskDisplayName, retainCoordinates bool) { +func ProtectPublicFields(value any, maskPersonalName, maskDisplayName, retainCoordinates bool) { switch data := value.(type) { case map[string]any: if phone, ok := data["phone"].(string); ok && phone != "" { - data["phone_masked"] = maskPhone(phone) + data["phone_masked"] = MaskPhone(phone) } delete(data, "phone") + for _, key := range []string{"contact_phone", "recipient_phone"} { + if phone, ok := data[key].(string); ok && phone != "" { + data[key+"_masked"] = MaskPhone(phone) + } + delete(data, key) + } + for _, key := range []string{"contact_name", "recipient_name"} { + if name, ok := data[key].(string); ok && name != "" { + data[key+"_masked"] = MaskPersonalNameValue(name) + } + delete(data, key) + } for key := range sensitiveResponseFields { delete(data, key) } if maskPersonalName { if name, ok := data["name"].(string); ok && name != "" { - data["name_masked"] = maskPersonalNameValue(name) + data["name_masked"] = MaskPersonalNameValue(name) } delete(data, "name") if name, ok := data["real_name"].(string); ok && name != "" { - data["real_name_masked"] = maskPersonalNameValue(name) + data["real_name_masked"] = MaskPersonalNameValue(name) } delete(data, "real_name") } if maskDisplayName { if name, ok := data["display_name"].(string); ok && name != "" { - data["display_name_masked"] = maskPersonalNameValue(name) + data["display_name_masked"] = MaskPersonalNameValue(name) } delete(data, "display_name") } @@ -233,16 +190,16 @@ func protectPublicFields(value any, maskPersonalName, maskDisplayName, retainCoo delete(data, "latitude") } for _, item := range data { - protectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates) + ProtectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates) } case []any: for _, item := range data { - protectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates) + ProtectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates) } } } -func maskPersonalNameValue(name string) string { +func MaskPersonalNameValue(name string) string { runes := []rune(name) if len(runes) == 0 { return "" @@ -253,7 +210,7 @@ func maskPersonalNameValue(name string) string { return string(runes[0]) + strings.Repeat("*", len(runes)-1) } -func hasPreciseLocationScope(ctx *gin.Context) bool { +func HasPreciseLocationScope(ctx *gin.Context) bool { claims, err := middleware.ParseAuth(ctx) return err == nil && claims.Extend["location_scope"] == "precise" } @@ -264,7 +221,7 @@ func updateResource(ctx *gin.Context, model any, allowedFields []string, relatio infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - values, err := resolveResourceRelations(input, allowedFields, relations, false) + values, err := ResolveResourceRelations(input, allowedFields, relations, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -273,23 +230,23 @@ func updateResource(ctx *gin.Context, model any, allowedFields []string, relatio infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, model, values, append(allowedFields, relationColumns(relations)...)) + UpdateAllowedByIdentity(ctx, model, values, append(allowedFields, relationColumns(relations)...)) } -func prepareResourceValues(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) (map[string]any, error) { +func PrepareResourceValues(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) (map[string]any, error) { var input map[string]any if err := ctx.ShouldBindJSON(&input); err != nil || len(input) == 0 { return nil, errors.New("invalid resource payload") } - values, err := resolveResourceRelations(input, allowedFields, relations, true) + values, err := ResolveResourceRelations(input, allowedFields, relations, true) if err != nil || len(values) == 0 { return nil, errors.New("invalid resource payload") } return values, nil } -func resolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) { - values := filterFields(input, allowedFields) +func ResolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) { + values := FilterFields(input, allowedFields) for _, relation := range relations { raw, exists := input[relation.Input] if !exists { @@ -309,7 +266,7 @@ func resolveResourceRelations(input map[string]any, allowedFields []string, rela if strings.TrimSpace(identity) == "" { return nil, errors.New("invalid relation identity") } - id, err := resolveIdentityID(relation.Model, identity, true) + id, err := ResolveIdentityID(relation.Model, identity, true) if err != nil { return nil, err } @@ -318,9 +275,9 @@ func resolveResourceRelations(input map[string]any, allowedFields []string, rela return values, nil } -// resolveIdentityID is the only boundary that converts a public identity to a +// ResolveIdentityID is the only boundary that converts a public identity to a // persistence-only numeric key. Callers must never bind a client supplied ID. -func resolveIdentityID(model any, identity string, required bool) (uint64, error) { +func ResolveIdentityID(model any, identity string, required bool) (uint64, error) { identity = strings.TrimSpace(identity) if identity == "" { if required { @@ -343,9 +300,9 @@ func relationColumns(relations []ResourceRelation) []string { return columns } -// resourceResponse strips database surrogate IDs from API data. Business +// ResourceResponse strips database surrogate IDs from API data. Business // identities are the only public relation keys accepted or returned. -func resourceResponse(value any) any { +func ResourceResponse(value any) any { encoded, err := json.Marshal(value) if err != nil { return value @@ -357,10 +314,10 @@ func resourceResponse(value any) any { return stripInternalIDs(decoded) } -// publicResourceResponse additionally resolves persisted relation keys into +// 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, error) { +func PublicResourceResponse(value any) (any, error) { encoded, err := json.Marshal(value) if err != nil { return nil, err @@ -373,26 +330,28 @@ func publicResourceResponse(value any) (any, error) { } 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{}, - "product_type_id": &models.ProductType{}, - "product_info_id": &models.ProductInfo{}, - "warehouse_id": &models.ProductWarehouse{}, - "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{}, - "wallet_basic_id": &models.WalletBasic{}, - "wallet_payment_id": &models.WalletPayment{}, - "wallet_bank_id": &models.WalletBank{}, - "related_record_id": &models.WalletRecord{}, + "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{}, + "product_type_id": &models.ProductType{}, + "product_info_id": &models.ProductInfo{}, + "warehouse_id": &models.ProductWarehouse{}, + "ec_category_id": &models.EcCategory{}, + "ec_product_id": &models.EcProduct{}, + "ec_order_id": &models.EcOrder{}, + "gasorder_contract_id": &models.GasorderContract{}, + "gasorder_contract_product_id": &models.GasorderContractProduct{}, + "gasorder_basic_id": &models.GasorderBasic{}, + "gasorder_track_id": &models.GasorderTrack{}, + "platform_role_id": &models.PlatformRole{}, + "platform_menu_id": &models.PlatformMenu{}, + "wallet_basic_id": &models.WalletBasic{}, + "wallet_payment_id": &models.WalletPayment{}, + "wallet_bank_id": &models.WalletBank{}, + "related_record_id": &models.WalletRecord{}, } var relationIdentityKeys = map[string]string{ @@ -540,122 +499,3 @@ func stripInternalIDs(value any) any { } return value } - -// GetEcOrder returns the order together with its immutable item snapshots. -func GetEcOrder(ctx *gin.Context) { - var order models.EcOrder - if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil { - respondRecordError(ctx, err) - return - } - var items []models.EcOrderItem - if err := impl.DBService.Where("ec_order_id = ?", order.ID).Order("id asc").Find(&items).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - response, err := publicResourceResponse(gin.H{"order": order, "items": items}) - if err != nil { - infra.Response.Error(ctx, err) - return - } - infra.Response.Success(ctx, response) -} - -// GetDeliveryTrack returns time-ordered points. Precise coordinates are only -// exposed to tokens explicitly granted the location_scope=precise claim. -func GetDeliveryTrack(ctx *gin.Context) { - var track models.DeliveryTrack - if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&track).Error; err != nil { - respondRecordError(ctx, err) - return - } - var points []models.DeliveryTrackPoint - if err := impl.DBService.Where("delivery_track_id = ?", track.ID).Order("occurred_at asc").Find(&points).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - if !hasPreciseLocationScope(ctx) { - for index := range points { - points[index].Longitude = "" - points[index].Latitude = "" - } - } - response, err := publicResourceResponse(gin.H{"track": track, "points": points}) - if err != nil { - infra.Response.Error(ctx, err) - return - } - infra.Response.Success(ctx, response) -} - -type ecCategoryView struct { - Identity string `json:"identity"` - ParentIdentity string `json:"parent_identity,omitempty"` - Name string `json:"name"` - SortNo int `json:"sort_no"` - Status string `json:"status"` -} - -func ecCategoryViews(list []models.EcCategory) ([]ecCategoryView, error) { - parents := make(map[uint64]string) - for _, item := range list { - if item.ParentID != 0 { - parents[item.ParentID] = "" - } - } - if len(parents) > 0 { - var rows []struct { - ID uint64 - Identity string - } - ids := make([]uint64, 0, len(parents)) - for id := range parents { - ids = append(ids, id) - } - if err := impl.DBService.Model(&models.EcCategory{}).Select("id", "identity").Where("id IN ?", ids).Find(&rows).Error; err != nil { - return nil, err - } - for _, row := range rows { - parents[row.ID] = row.Identity - } - } - views := make([]ecCategoryView, 0, len(list)) - for _, item := range list { - views = append(views, ecCategoryView{Identity: item.Identity, ParentIdentity: parents[item.ParentID], Name: item.Name, SortNo: item.SortNo, Status: item.Status}) - } - return views, nil -} - -func ListEcCategory(ctx *gin.Context) { - page, size := pageSize(ctx) - var list []models.EcCategory - var total int64 - if err := impl.DBService.Model(&models.EcCategory{}).Count(&total).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - if err := impl.DBService.Order("sort_no asc, id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - views, err := ecCategoryViews(list) - if err != nil { - infra.Response.Error(ctx, err) - return - } - infra.Response.Success(ctx, gin.H{"total": total, "list": views}) -} - -func GetEcCategory(ctx *gin.Context) { - var category models.EcCategory - if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&category).Error; err != nil { - respondRecordError(ctx, err) - return - } - views, err := ecCategoryViews([]models.EcCategory{category}) - if err != nil { - infra.Response.Error(ctx, err) - return - } - infra.Response.Success(ctx, views[0]) -} diff --git a/backend/api/internal/logic/common/resource_test.go b/backend/api/internal/logic/common/resource_test.go new file mode 100644 index 0000000..40d855a --- /dev/null +++ b/backend/api/internal/logic/common/resource_test.go @@ -0,0 +1,58 @@ +package common + +import ( + "net/http" + "testing" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" +) + +func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) { + got := FilterFields(map[string]any{"name": "n", "password_hash": "x"}, []string{"name"}) + if len(got) != 1 || got["name"] != "n" { + t.Fatalf("unexpected filtered fields: %#v", got) + } +} + +func TestResourceResponseStripsInternalIDsRecursively(t *testing.T) { + got := ResourceResponse(map[string]any{ + "id": uint64(1), "identity": "root", + "child": map[string]any{"gasorder_basic_id": uint64(2), "identity": "child"}, + }).(map[string]any) + if _, exists := got["id"]; exists { + t.Fatal("root database ID was exposed") + } + child := got["child"].(map[string]any) + if _, exists := child["gasorder_basic_id"]; exists { + t.Fatal("relation database ID was exposed") + } +} + +func TestPublicFieldProtectionMasksGasorderContacts(t *testing.T) { + value := map[string]any{"contact_name": "张三", "contact_phone": "13800138000"} + ProtectPublicFields(value, false, false, false) + if _, exists := value["contact_name"]; exists { + t.Fatal("contact name remains public") + } + if _, exists := value["contact_phone"]; exists { + t.Fatal("contact phone remains public") + } +} + +func TestResourceRelationOptionalEmptyIdentityClearsRelation(t *testing.T) { + values, err := ResolveResourceRelations( + map[string]any{"warehouse_identity": ""}, + nil, + []ResourceRelation{{Input: "warehouse_identity", Column: "warehouse_id", Model: &models.ProductWarehouse{}}}, + false, + ) + if err != nil || values["warehouse_id"] != uint64(0) { + t.Fatalf("optional relation clear = (%#v, %v)", values, err) + } +} + +func TestCommonMethodModesRemainHTTPCompatible(t *testing.T) { + if http.MethodGet == "" || http.MethodPost == "" { + t.Fatal("standard HTTP methods unavailable") + } +} diff --git a/backend/api/internal/logic/platform/health.go b/backend/api/internal/logic/platform/dashboard.go similarity index 100% rename from backend/api/internal/logic/platform/health.go rename to backend/api/internal/logic/platform/dashboard.go diff --git a/backend/api/internal/logic/platform/health_test.go b/backend/api/internal/logic/platform/dashboard_test.go similarity index 100% rename from backend/api/internal/logic/platform/health_test.go rename to backend/api/internal/logic/platform/dashboard_test.go diff --git a/backend/api/internal/logic/platform/delivery.go b/backend/api/internal/logic/platform/delivery_basic.go similarity index 63% rename from backend/api/internal/logic/platform/delivery.go rename to backend/api/internal/logic/platform/delivery_basic.go index 0d3f545..e29d117 100644 --- a/backend/api/internal/logic/platform/delivery.go +++ b/backend/api/internal/logic/platform/delivery_basic.go @@ -4,15 +4,16 @@ import ( "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" ) // ListDeliveryBasic 查询配送点分页列表。 -func ListDeliveryBasic(ctx *gin.Context) { listPage[models.DeliveryBasic](ctx) } +func ListDeliveryBasic(ctx *gin.Context) { common.ListPage[models.DeliveryBasic](ctx) } // GetDeliveryBasic 查询一个配送点。 -func GetDeliveryBasic(ctx *gin.Context) { getByIdentity[models.DeliveryBasic](ctx) } +func GetDeliveryBasic(ctx *gin.Context) { common.GetByIdentity[models.DeliveryBasic](ctx) } // CreateDeliveryBasic 创建配送点档案。 func CreateDeliveryBasic(ctx *gin.Context) { @@ -27,17 +28,17 @@ func CreateDeliveryBasic(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + gasBasicID, err := common.ResolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - delivery := models.DeliveryBasic{Entity: newEntity("draft"), DeliveryCode: request.DeliveryCode, GasBasicID: gasBasicID, Name: request.Name, Principal: request.Principal, Address: request.Address} + delivery := models.DeliveryBasic{Entity: common.NewEntity("draft"), DeliveryCode: request.DeliveryCode, GasBasicID: gasBasicID, Name: request.Name, Principal: request.Principal, Address: request.Address} if err := impl.DBService.Create(&delivery).Error; err != nil { infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, delivery) + common.RespondCreatedResource(ctx, delivery) } // UpdateDeliveryBasic 更新配送点基础资料。 @@ -52,10 +53,10 @@ func UpdateDeliveryBasic(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + gasBasicID, err := common.ResolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.DeliveryBasic{}, gin.H{"gas_basic_id": gasBasicID, "name": request.Name, "principal": request.Principal, "address": request.Address}, []string{"gas_basic_id", "name", "principal", "address"}) + common.UpdateAllowedByIdentity(ctx, &models.DeliveryBasic{}, gin.H{"gas_basic_id": gasBasicID, "name": request.Name, "principal": request.Principal, "address": request.Address}, []string{"gas_basic_id", "name", "principal", "address"}) } diff --git a/backend/api/internal/logic/platform/ec_category.go b/backend/api/internal/logic/platform/ec_category.go new file mode 100644 index 0000000..406dbfd --- /dev/null +++ b/backend/api/internal/logic/platform/ec_category.go @@ -0,0 +1,81 @@ +package platform + +import ( + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +type ecCategoryView struct { + Identity string `json:"identity"` + ParentIdentity string `json:"parent_identity,omitempty"` + Name string `json:"name"` + SortNo int `json:"sort_no"` + Status string `json:"status"` +} + +func ecCategoryViews(list []models.EcCategory) ([]ecCategoryView, error) { + parents := make(map[uint64]string) + for _, item := range list { + if item.ParentID != 0 { + parents[item.ParentID] = "" + } + } + if len(parents) > 0 { + var rows []struct { + ID uint64 + Identity string + } + ids := make([]uint64, 0, len(parents)) + for id := range parents { + ids = append(ids, id) + } + if err := impl.DBService.Model(&models.EcCategory{}).Select("id", "identity").Where("id IN ?", ids).Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + parents[row.ID] = row.Identity + } + } + views := make([]ecCategoryView, 0, len(list)) + for _, item := range list { + views = append(views, ecCategoryView{Identity: item.Identity, ParentIdentity: parents[item.ParentID], Name: item.Name, SortNo: item.SortNo, Status: item.Status}) + } + return views, nil +} + +func ListEcCategory(ctx *gin.Context) { + page, size := common.PageSize(ctx) + var list []models.EcCategory + var total int64 + if err := impl.DBService.Model(&models.EcCategory{}).Count(&total).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := impl.DBService.Order("sort_no asc, id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + views, err := ecCategoryViews(list) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": total, "list": views}) +} + +func GetEcCategory(ctx *gin.Context) { + var category models.EcCategory + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&category).Error; err != nil { + common.RespondRecordError(ctx, err) + return + } + views, err := ecCategoryViews([]models.EcCategory{category}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, views[0]) +} diff --git a/backend/api/internal/logic/platform/ec_order.go b/backend/api/internal/logic/platform/ec_order.go new file mode 100644 index 0000000..4e38810 --- /dev/null +++ b/backend/api/internal/logic/platform/ec_order.go @@ -0,0 +1,29 @@ +package platform + +import ( + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +// GetEcOrder returns the order together with its immutable item snapshots. +func GetEcOrder(ctx *gin.Context) { + var order models.EcOrder + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil { + common.RespondRecordError(ctx, err) + return + } + var items []models.EcOrderItem + if err := impl.DBService.Where("ec_order_id = ?", order.ID).Order("id asc").Find(&items).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + response, err := common.PublicResourceResponse(gin.H{"order": order, "items": items}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, response) +} diff --git a/backend/api/internal/logic/platform/fin_settlement.go b/backend/api/internal/logic/platform/fin_settlement.go new file mode 100644 index 0000000..b68ef31 --- /dev/null +++ b/backend/api/internal/logic/platform/fin_settlement.go @@ -0,0 +1,68 @@ +package platform + +import ( + "bytes" + "encoding/json" + "errors" + "io" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +func FinSettlementHandlers() (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { + fields := []string{"settlement_no", "subject_type", "subject_id", "period_start", "period_end"} + return func(ctx *gin.Context) { common.ListResource(ctx, &models.FinSettlement{}) }, + func(ctx *gin.Context) { + if err := rewriteSettlementSubject(ctx); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + _, create, _, _ := common.ResourceHandlers(&models.FinSettlement{}, fields, fields) + create(ctx) + }, + func(ctx *gin.Context) { common.GetResource(ctx, &models.FinSettlement{}) }, + func(ctx *gin.Context) { + if err := rewriteSettlementSubject(ctx); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + _, _, _, update := common.ResourceHandlers(&models.FinSettlement{}, fields, fields) + update(ctx) + } +} + +func rewriteSettlementSubject(ctx *gin.Context) error { + var input map[string]any + if err := ctx.ShouldBindJSON(&input); err != nil { + return err + } + subjectType, _ := input["subject_type"].(string) + identity, _ := input["subject_identity"].(string) + var model any + switch subjectType { + case "gas", "gas_basic": + model = &models.GasBasic{} + case "delivery", "delivery_basic": + model = &models.DeliveryBasic{} + case "staff", "staff_account": + model = &models.StaffAccount{} + default: + return errors.New("invalid settlement subject") + } + id, err := common.ResolveIdentityID(model, identity, true) + if err != nil { + return err + } + delete(input, "subject_identity") + input["subject_id"] = id + encoded, err := json.Marshal(input) + if err != nil { + return err + } + ctx.Request.Body = io.NopCloser(bytes.NewReader(encoded)) + return nil +} diff --git a/backend/api/internal/logic/platform/account.go b/backend/api/internal/logic/platform/gas_account_delivery_account.go similarity index 60% rename from backend/api/internal/logic/platform/account.go rename to backend/api/internal/logic/platform/gas_account_delivery_account.go index 4d055a4..c2a5f3a 100644 --- a/backend/api/internal/logic/platform/account.go +++ b/backend/api/internal/logic/platform/gas_account_delivery_account.go @@ -4,6 +4,7 @@ import ( "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" @@ -30,8 +31,8 @@ func passwordHash(password string) (string, error) { return string(hash), err } -func ListGasAccount(ctx *gin.Context) { listPage[models.GasAccount](ctx) } -func GetGasAccount(ctx *gin.Context) { getByIdentity[models.GasAccount](ctx) } +func ListGasAccount(ctx *gin.Context) { common.ListPage[models.GasAccount](ctx) } +func GetGasAccount(ctx *gin.Context) { common.GetByIdentity[models.GasAccount](ctx) } func CreateGasAccount(ctx *gin.Context) { var request accountRequest @@ -39,7 +40,7 @@ func CreateGasAccount(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, true) + gasBasicID, err := common.ResolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, true) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -49,12 +50,12 @@ func CreateGasAccount(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - account := models.GasAccount{Entity: newEntity("enabled"), GasBasicID: gasBasicID, Username: request.Username, DisplayName: request.DisplayName, PasswordHash: hash, RoleCode: request.RoleCode} + account := models.GasAccount{Entity: common.NewEntity("enabled"), GasBasicID: gasBasicID, Username: request.Username, DisplayName: request.DisplayName, PasswordHash: hash, RoleCode: request.RoleCode} if err := impl.DBService.Create(&account).Error; err != nil { infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, account) + common.RespondCreatedResource(ctx, account) } func UpdateGasAccount(ctx *gin.Context) { @@ -63,16 +64,16 @@ func UpdateGasAccount(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, true) + gasBasicID, err := common.ResolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, true) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.GasAccount{}, gin.H{"gas_basic_id": gasBasicID, "display_name": request.DisplayName, "role_code": request.RoleCode}, []string{"gas_basic_id", "display_name", "role_code"}) + common.UpdateAllowedByIdentity(ctx, &models.GasAccount{}, gin.H{"gas_basic_id": gasBasicID, "display_name": request.DisplayName, "role_code": request.RoleCode}, []string{"gas_basic_id", "display_name", "role_code"}) } -func ListDeliveryAccount(ctx *gin.Context) { listPage[models.DeliveryAccount](ctx) } -func GetDeliveryAccount(ctx *gin.Context) { getByIdentity[models.DeliveryAccount](ctx) } +func ListDeliveryAccount(ctx *gin.Context) { common.ListPage[models.DeliveryAccount](ctx) } +func GetDeliveryAccount(ctx *gin.Context) { common.GetByIdentity[models.DeliveryAccount](ctx) } func CreateDeliveryAccount(ctx *gin.Context) { var request accountRequest @@ -80,7 +81,7 @@ func CreateDeliveryAccount(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, true) + deliveryBasicID, err := common.ResolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, true) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -90,12 +91,12 @@ func CreateDeliveryAccount(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - account := models.DeliveryAccount{Entity: newEntity("enabled"), DeliveryBasicID: deliveryBasicID, Username: request.Username, DisplayName: request.DisplayName, PasswordHash: hash, RoleCode: request.RoleCode} + account := models.DeliveryAccount{Entity: common.NewEntity("enabled"), DeliveryBasicID: deliveryBasicID, Username: request.Username, DisplayName: request.DisplayName, PasswordHash: hash, RoleCode: request.RoleCode} if err := impl.DBService.Create(&account).Error; err != nil { infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, account) + common.RespondCreatedResource(ctx, account) } func UpdateDeliveryAccount(ctx *gin.Context) { @@ -104,10 +105,10 @@ func UpdateDeliveryAccount(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, true) + deliveryBasicID, err := common.ResolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, true) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.DeliveryAccount{}, gin.H{"delivery_basic_id": deliveryBasicID, "display_name": request.DisplayName, "role_code": request.RoleCode}, []string{"delivery_basic_id", "display_name", "role_code"}) + common.UpdateAllowedByIdentity(ctx, &models.DeliveryAccount{}, gin.H{"delivery_basic_id": deliveryBasicID, "display_name": request.DisplayName, "role_code": request.RoleCode}, []string{"delivery_basic_id", "display_name", "role_code"}) } diff --git a/backend/api/internal/logic/platform/gas.go b/backend/api/internal/logic/platform/gas_basic.go similarity index 67% rename from backend/api/internal/logic/platform/gas.go rename to backend/api/internal/logic/platform/gas_basic.go index ee1e594..34c6f73 100644 --- a/backend/api/internal/logic/platform/gas.go +++ b/backend/api/internal/logic/platform/gas_basic.go @@ -4,15 +4,16 @@ import ( "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" ) // ListGasBasic 查询可燃气体站分页列表。 -func ListGasBasic(ctx *gin.Context) { listPage[models.GasBasic](ctx) } +func ListGasBasic(ctx *gin.Context) { common.ListPage[models.GasBasic](ctx) } // GetGasBasic 查询一个可燃气体站。 -func GetGasBasic(ctx *gin.Context) { getByIdentity[models.GasBasic](ctx) } +func GetGasBasic(ctx *gin.Context) { common.GetByIdentity[models.GasBasic](ctx) } // CreateGasBasic 创建可燃气体站档案。 func CreateGasBasic(ctx *gin.Context) { @@ -21,12 +22,12 @@ func CreateGasBasic(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - request.Entity = newEntity("draft") + request.Entity = common.NewEntity("draft") if err := impl.DBService.Create(&request).Error; err != nil { infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, request) + common.RespondCreatedResource(ctx, request) } // UpdateGasBasic 更新可燃气体站基础资料。 @@ -43,5 +44,5 @@ func UpdateGasBasic(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.GasBasic{}, gin.H{"name": request.Name, "credit_code": request.CreditCode, "principal": request.Principal, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude}, []string{"name", "credit_code", "principal", "address", "longitude", "latitude"}) + common.UpdateAllowedByIdentity(ctx, &models.GasBasic{}, gin.H{"name": request.Name, "credit_code": request.CreditCode, "principal": request.Principal, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude}, []string{"name", "credit_code", "principal", "address", "longitude", "latitude"}) } diff --git a/backend/api/internal/logic/platform/gasorder_workflow.go b/backend/api/internal/logic/platform/gasorder_workflow.go new file mode 100644 index 0000000..fce0eb8 --- /dev/null +++ b/backend/api/internal/logic/platform/gasorder_workflow.go @@ -0,0 +1,615 @@ +package platform + +import ( + "errors" + "strings" + "time" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "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" + "gorm.io/gorm/clause" +) + +var gasorderCreatorModels = map[string]any{ + "user": &models.UserAccount{}, "staff": &models.StaffAccount{}, + "delivery": &models.DeliveryBasic{}, "gas": &models.GasBasic{}, +} + +func ListGasorderContract(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderContract{}) } +func GetGasorderContract(ctx *gin.Context) { getGasorderContract(ctx) } +func ListGasorderContractProduct(ctx *gin.Context) { + common.ListResource(ctx, &models.GasorderContractProduct{}) +} +func GetGasorderContractProduct(ctx *gin.Context) { + common.GetResource(ctx, &models.GasorderContractProduct{}) +} +func ListGasorderContractRevision(ctx *gin.Context) { + common.ListResource(ctx, &models.GasorderContractRevision{}) +} +func GetGasorderContractRevision(ctx *gin.Context) { + common.GetResource(ctx, &models.GasorderContractRevision{}) +} +func ListGasorderBasic(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderBasic{}) } +func GetGasorderBasic(ctx *gin.Context) { getGasorderBasic(ctx) } +func ListGasorderItem(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderItem{}) } +func GetGasorderItem(ctx *gin.Context) { common.GetResource(ctx, &models.GasorderItem{}) } +func ListGasorderAssign(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderAssign{}) } +func GetGasorderAssign(ctx *gin.Context) { common.GetResource(ctx, &models.GasorderAssign{}) } +func ListGasorderStatus(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderStatus{}) } +func GetGasorderStatus(ctx *gin.Context) { common.GetResource(ctx, &models.GasorderStatus{}) } +func ListGasorderTrack(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderTrack{}) } +func GetGasorderTrack(ctx *gin.Context) { getGasorderTrack(ctx) } +func ListGasorderTrackPoint(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderTrackPoint{}) } +func GetGasorderTrackPoint(ctx *gin.Context) { common.GetResource(ctx, &models.GasorderTrackPoint{}) } +func ListGasorderConfirm(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderConfirm{}) } +func GetGasorderConfirm(ctx *gin.Context) { common.GetResource(ctx, &models.GasorderConfirm{}) } +func ListGasorderPayment(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderPayment{}) } +func GetGasorderPayment(ctx *gin.Context) { common.GetResource(ctx, &models.GasorderPayment{}) } + +func getGasorderContract(ctx *gin.Context) { + var contract models.GasorderContract + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&contract).Error; err != nil { + common.RespondRecordError(ctx, err) + return + } + var products []models.GasorderContractProduct + var revisions []models.GasorderContractRevision + if err := impl.DBService.Where("gasorder_contract_id = ?", contract.ID).Order("bound_at asc, id asc").Find(&products).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := impl.DBService.Where("gasorder_contract_id = ?", contract.ID).Order("occurred_at asc, id asc").Find(&revisions).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + response, err := common.PublicResourceResponse(gin.H{"contract": contract, "products": products, "revisions": revisions}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, common.ProtectPreciseLocation(ctx, &models.GasorderContract{}, response)) +} + +func getGasorderBasic(ctx *gin.Context) { + var order models.GasorderBasic + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil { + common.RespondRecordError(ctx, err) + return + } + var items []models.GasorderItem + var assignments []models.GasorderAssign + var statuses []models.GasorderStatus + var tracks []models.GasorderTrack + var confirmations []models.GasorderConfirm + var payments []models.GasorderPayment + loaders := []struct { + order string + value any + }{ + {"id asc", &items}, {"assigned_at asc, id asc", &assignments}, + {"occurred_at asc, id asc", &statuses}, {"attempt_no asc, id asc", &tracks}, + {"confirmed_at asc, id asc", &confirmations}, {"attempt_no asc, id asc", &payments}, + } + for _, loader := range loaders { + if err := impl.DBService.Where("gasorder_basic_id = ?", order.ID).Order(loader.order).Find(loader.value).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + } + response, err := common.PublicResourceResponse(gin.H{ + "order": order, "items": items, "assignments": assignments, "statuses": statuses, + "tracks": tracks, "confirmations": confirmations, "payments": payments, + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, common.ProtectPreciseLocation(ctx, &models.GasorderBasic{}, response)) +} + +func CreateGasorderContract(ctx *gin.Context) { + var request struct { + ContractNo string `json:"contract_no" binding:"required,max=64"` + UserAccountIdentity string `json:"user_account_identity" binding:"required"` + GasBasicIdentity string `json:"gas_basic_identity" binding:"required"` + DeliveryIdentity string `json:"delivery_basic_identity"` + Title string `json:"title" binding:"required,max=255"` + Terms string `json:"terms"` + FileURI string `json:"file_uri" binding:"max=512"` + DefaultDeliveryFee int64 `json:"default_delivery_fee"` + SignedAt time.Time `json:"signed_at" binding:"required"` + EffectiveAt time.Time `json:"effective_at" binding:"required"` + ExpiredAt *time.Time `json:"expired_at"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || request.DefaultDeliveryFee < 0 || + request.SignedAt.IsZero() || request.EffectiveAt.IsZero() || + (request.ExpiredAt != nil && !request.ExpiredAt.After(request.EffectiveAt)) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + userID, err := common.ResolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + gasID, err := common.ResolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + deliveryID, err := common.ResolveIdentityID(&models.DeliveryBasic{}, request.DeliveryIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + contract := models.GasorderContract{ + Entity: models.Entity{Identity: models.NewIdentity(), Status: "draft", Version: 1}, + ContractNo: request.ContractNo, UserAccountID: userID, GasBasicID: gasID, DeliveryBasicID: deliveryID, + Title: request.Title, Terms: request.Terms, FileURI: request.FileURI, DefaultDeliveryFee: request.DefaultDeliveryFee, + SignedAt: request.SignedAt, EffectiveAt: request.EffectiveAt, ExpiredAt: request.ExpiredAt, + } + if err := impl.DBService.Create(&contract).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + common.RespondCreatedResource(ctx, contract) +} + +func UpdateGasorderContract(ctx *gin.Context) { + var request struct { + DeliveryIdentity string `json:"delivery_basic_identity"` + Title string `json:"title" binding:"required,max=255"` + Terms string `json:"terms"` + FileURI string `json:"file_uri" binding:"max=512"` + DefaultDeliveryFee int64 `json:"default_delivery_fee"` + SignedAt time.Time `json:"signed_at" binding:"required"` + EffectiveAt time.Time `json:"effective_at" binding:"required"` + ExpiredAt *time.Time `json:"expired_at"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || request.DefaultDeliveryFee < 0 || + (request.ExpiredAt != nil && !request.ExpiredAt.After(request.EffectiveAt)) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + deliveryID, err := common.ResolveIdentityID(&models.DeliveryBasic{}, request.DeliveryIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + result := impl.DBService.Model(&models.GasorderContract{}). + Where("identity = ? AND status = ?", ctx.Param("identity"), "draft"). + Updates(map[string]any{"delivery_basic_id": deliveryID, "title": request.Title, "terms": request.Terms, + "file_uri": request.FileURI, "default_delivery_fee": request.DefaultDeliveryFee, + "signed_at": request.SignedAt, "effective_at": request.EffectiveAt, "expired_at": request.ExpiredAt}) + if result.Error != nil || result.RowsAffected != 1 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"updated": true}) +} + +func ActivateGasorderContract(ctx *gin.Context) { + changeGasorderContract(ctx, "activate", "active") +} + +func TerminateGasorderContract(ctx *gin.Context) { + changeGasorderContract(ctx, "terminate", "terminated") +} + +func RenewGasorderContract(ctx *gin.Context) { + var request struct { + EffectiveAt time.Time `json:"effective_at" binding:"required"` + ExpiredAt *time.Time `json:"expired_at"` + Reason string `json:"reason" binding:"required"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || + (request.ExpiredAt != nil && !request.ExpiredAt.After(request.EffectiveAt)) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + operatorIdentity, operatorName := walletOperator(ctx) + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var contract models.GasorderContract + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&contract).Error; err != nil { + return err + } + if contract.Status != "active" && contract.Status != "expired" && contract.Status != "terminated" { + return errors.New("contract cannot be renewed") + } + if err := tx.Model(&contract).Updates(map[string]any{"status": "active", "effective_at": request.EffectiveAt, "expired_at": request.ExpiredAt}).Error; err != nil { + return err + } + contract.Status, contract.EffectiveAt, contract.ExpiredAt = "active", request.EffectiveAt, request.ExpiredAt + return tx.Create(contractRevision(contract, "renew", request.Reason, operatorIdentity, operatorName)).Error + }) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"updated": true}) +} + +func changeGasorderContract(ctx *gin.Context, action, target string) { + var request struct { + Reason string `json:"reason" binding:"required"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + operatorIdentity, operatorName := walletOperator(ctx) + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var contract models.GasorderContract + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&contract).Error; err != nil { + return err + } + if action == "activate" && contract.Status != "draft" && contract.Status != "terminated" { + return errors.New("contract cannot be activated") + } + if action == "terminate" && contract.Status != "active" { + return errors.New("contract cannot be terminated") + } + if target == "active" { + now := time.Now() + if contract.EffectiveAt.After(now) || (contract.ExpiredAt != nil && !contract.ExpiredAt.After(now)) { + return errors.New("contract outside effective period") + } + var productCount int64 + if err := tx.Model(&models.GasorderContractProduct{}). + Where("gasorder_contract_id = ? AND unbound_at IS NULL", contract.ID).Count(&productCount).Error; err != nil || productCount == 0 { + return errors.New("contract has no active product") + } + } + if err := tx.Model(&contract).Update("status", target).Error; err != nil { + return err + } + contract.Status = target + return tx.Create(contractRevision(contract, action, request.Reason, operatorIdentity, operatorName)).Error + }) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"updated": true, "status": target}) +} + +func contractRevision(contract models.GasorderContract, action, reason, operatorIdentity, operatorName string) *models.GasorderContractRevision { + return &models.GasorderContractRevision{ + Entity: models.Entity{Identity: models.NewIdentity(), Status: "recorded", Version: 1}, + GasorderContractID: contract.ID, Action: action, ContractStatus: contract.Status, + EffectiveAt: contract.EffectiveAt, ExpiredAt: contract.ExpiredAt, + OperatorIdentity: operatorIdentity, OperatorName: operatorName, OccurredAt: time.Now(), Reason: reason, + } +} + +func BindGasorderContractProduct(ctx *gin.Context) { + var request struct { + ContractIdentity string `json:"gasorder_contract_identity" binding:"required"` + ProductIdentity string `json:"product_info_identity" binding:"required"` + UnitPrice int64 `json:"unit_price"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || request.UnitPrice < 0 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var contract models.GasorderContract + if err := impl.DBService.Where("identity = ?", request.ContractIdentity).First(&contract).Error; err != nil { + common.RespondRecordError(ctx, err) + return + } + if contract.Status != "draft" && contract.Status != "active" { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var product models.ProductInfo + if err := impl.DBService.Where("identity = ?", request.ProductIdentity).First(&product).Error; err != nil || + !product.IsEnabled || product.Status == "scrapped" || product.UserAccountID != contract.UserAccountID { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var productType models.ProductType + if err := impl.DBService.Where("id = ?", product.ProductTypeID).First(&productType).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + binding := models.GasorderContractProduct{ + Entity: models.Entity{Identity: models.NewIdentity(), Status: "bound", Version: 1}, + GasorderContractID: contract.ID, ProductInfoID: product.ID, ProductCode: product.Code, + ProductTypeName: productType.Name, ProductParams: product.Params, UnitPrice: request.UnitPrice, BoundAt: time.Now(), + } + if err := impl.DBService.Create(&binding).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + common.RespondCreatedResource(ctx, binding) +} + +func UnbindGasorderContractProduct(ctx *gin.Context) { + now := time.Now() + result := impl.DBService.Model(&models.GasorderContractProduct{}). + Where("identity = ? AND unbound_at IS NULL", ctx.Param("identity")). + Updates(map[string]any{"status": "unbound", "unbound_at": &now}) + if result.Error != nil || result.RowsAffected != 1 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"updated": true}) +} + +func CreateGasorderBasic(ctx *gin.Context) { + var request struct { + RequestNo string `json:"request_no" binding:"required,max=128"` + ContractIdentity string `json:"gasorder_contract_identity" binding:"required"` + CreatorType string `json:"creator_type" binding:"required"` + CreatorIdentity string `json:"creator_identity" binding:"required"` + UserAddressIdentity string `json:"user_address_identity" binding:"required"` + ContractProductIdentities []string `json:"gasorder_contract_product_identities" binding:"required,min=1"` + ContactName string `json:"contact_name" binding:"required,max=64"` + ContactPhone string `json:"contact_phone" binding:"required,max=32"` + DiscountAmount int64 `json:"discount_amount"` + Remark string `json:"remark"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || request.DiscountAmount < 0 || gasorderCreatorModels[request.CreatorType] == nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + creatorID, err := common.ResolveIdentityID(gasorderCreatorModels[request.CreatorType], request.CreatorIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + operatorIdentity, operatorName := walletOperator(ctx) + var order models.GasorderBasic + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("request_no = ?", request.RequestNo).First(&order).Error; err == nil { + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var contract models.GasorderContract + if err := tx.Where("identity = ?", request.ContractIdentity).First(&contract).Error; err != nil { + return err + } + now := time.Now() + if contract.Status != "active" || contract.EffectiveAt.After(now) || (contract.ExpiredAt != nil && !contract.ExpiredAt.After(now)) { + return errors.New("contract is not active") + } + if request.CreatorType == "user" && creatorID != contract.UserAccountID { + return errors.New("user cannot create an order for another user") + } + if request.CreatorType == "gas" && creatorID != contract.GasBasicID { + return errors.New("gas station does not own the contract") + } + var address models.UserAddress + if err := tx.Where("identity = ? AND user_account_id = ?", request.UserAddressIdentity, contract.UserAccountID).First(&address).Error; err != nil { + return err + } + var bindings []models.GasorderContractProduct + if err := tx.Where("identity IN ? AND gasorder_contract_id = ? AND unbound_at IS NULL", + request.ContractProductIdentities, contract.ID).Find(&bindings).Error; err != nil { + return err + } + if len(bindings) != len(request.ContractProductIdentities) { + return errors.New("invalid contract product") + } + var productAmount int64 + for _, binding := range bindings { + var product models.ProductInfo + if err := tx.Where("id = ?", binding.ProductInfoID).First(&product).Error; err != nil || + !product.IsEnabled || product.Status == "scrapped" || product.UserAccountID != contract.UserAccountID { + return errors.New("contract product is no longer eligible") + } + var activeOrderCount int64 + if err := tx.Table("gasorder_item"). + Joins("JOIN gasorder_basic ON gasorder_basic.id = gasorder_item.gasorder_basic_id"). + Where("gasorder_item.product_info_id = ? AND gasorder_basic.status NOT IN ?", binding.ProductInfoID, []string{"completed", "cancelled"}). + Count(&activeOrderCount).Error; err != nil || activeOrderCount != 0 { + return errors.New("contract product already has an active order") + } + productAmount += binding.UnitPrice + } + payable := productAmount + contract.DefaultDeliveryFee - request.DiscountAmount + if payable <= 0 { + return errors.New("invalid payable amount") + } + order = models.GasorderBasic{ + Entity: models.Entity{Identity: models.NewIdentity(), Status: "created", Version: 1}, + OrderNo: models.NewIdentity(), RequestNo: request.RequestNo, GasorderContractID: contract.ID, + UserAccountID: contract.UserAccountID, CreatorType: request.CreatorType, CreatorID: creatorID, + CreatorIdentity: request.CreatorIdentity, GasBasicID: contract.GasBasicID, DeliveryBasicID: contract.DeliveryBasicID, + Address: address.Address, Longitude: address.Longitude, Latitude: address.Latitude, + ContactName: request.ContactName, ContactPhone: request.ContactPhone, + ProductAmount: productAmount, DeliveryFee: contract.DefaultDeliveryFee, + DiscountAmount: request.DiscountAmount, PayableAmount: payable, + OperatorIdentity: operatorIdentity, OperatorName: operatorName, Remark: request.Remark, + } + if err := tx.Create(&order).Error; err != nil { + return err + } + for _, binding := range bindings { + item := models.GasorderItem{ + Entity: models.Entity{Identity: models.NewIdentity(), Status: "ordered", Version: 1}, + GasorderBasicID: order.ID, GasorderContractProductID: binding.ID, ProductInfoID: binding.ProductInfoID, + ProductCode: binding.ProductCode, ProductTypeName: binding.ProductTypeName, + ProductParams: binding.ProductParams, UnitPrice: binding.UnitPrice, + } + if err := tx.Create(&item).Error; err != nil { + return err + } + } + return tx.Create(gasorderStatusRecord(order.ID, "", "created", "order created", operatorIdentity, operatorName)).Error + }) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + common.RespondCreatedResource(ctx, order) +} + +func AssignGasorderBasic(ctx *gin.Context) { + var request struct { + DeliveryIdentity string `json:"delivery_basic_identity" binding:"required"` + StaffIdentity string `json:"staff_account_identity" binding:"required"` + Reason string `json:"reason" binding:"required"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var delivery models.DeliveryBasic + if err := impl.DBService.Where("identity = ?", request.DeliveryIdentity).First(&delivery).Error; err != nil || delivery.Status != "enabled" { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var staff models.StaffAccount + if err := impl.DBService.Where("identity = ?", request.StaffIdentity).First(&staff).Error; err != nil || + staff.Status != "enabled" || staff.WorkStatus == "off_duty" { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + operatorIdentity, operatorName := walletOperator(ctx) + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var order models.GasorderBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil { + return err + } + if order.Status != "created" && order.Status != "assigned" { + return errors.New("order cannot be assigned") + } + previous := order.Status + if err := tx.Model(&order).Updates(map[string]any{ + "delivery_basic_id": delivery.ID, "staff_account_id": staff.ID, "status": "assigned", + }).Error; err != nil { + return err + } + assignment := models.GasorderAssign{ + Entity: models.Entity{Identity: models.NewIdentity(), Status: "recorded", Version: 1}, + GasorderBasicID: order.ID, GasBasicID: order.GasBasicID, DeliveryBasicID: delivery.ID, + StaffAccountID: staff.ID, AssignerIdentity: operatorIdentity, AssignerName: operatorName, + AssignedAt: time.Now(), Reason: request.Reason, + } + if err := tx.Create(&assignment).Error; err != nil { + return err + } + if previous != "assigned" { + return tx.Create(gasorderStatusRecord(order.ID, previous, "assigned", request.Reason, operatorIdentity, operatorName)).Error + } + return nil + }) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"updated": true, "status": "assigned"}) +} + +func GasorderStartFilling(ctx *gin.Context) { + transitionGasorder(ctx, "filling", map[string]bool{"assigned": true}) +} +func GasorderReady(ctx *gin.Context) { + transitionGasorder(ctx, "ready", map[string]bool{"filling": true}) +} +func GasorderCancel(ctx *gin.Context) { + transitionGasorder(ctx, "cancelled", map[string]bool{"created": true, "assigned": true}) +} + +func GasorderException(ctx *gin.Context) { + transitionGasorder(ctx, "exception", map[string]bool{"filling": true, "ready": true, "delivering": true, "awaiting_confirmation": true}) +} + +func GasorderRecover(ctx *gin.Context) { + var request struct { + Reason string `json:"reason" binding:"required"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + operatorIdentity, operatorName := walletOperator(ctx) + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var order models.GasorderBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil { + return err + } + if order.Status != "exception" || order.PreviousStatus == "" { + return errors.New("order cannot recover") + } + target := order.PreviousStatus + if err := tx.Model(&order).Updates(map[string]any{"status": target, "previous_status": ""}).Error; err != nil { + return err + } + return tx.Create(gasorderStatusRecord(order.ID, "exception", target, request.Reason, operatorIdentity, operatorName)).Error + }) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"updated": true}) +} + +func transitionGasorder(ctx *gin.Context, target string, allowed map[string]bool) { + var request struct { + Reason string `json:"reason" binding:"required"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + operatorIdentity, operatorName := walletOperator(ctx) + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var order models.GasorderBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil { + return err + } + if !allowed[order.Status] || (target == "filling" && order.DeliveryBasicID == 0) { + return errors.New("invalid order transition") + } + updates := map[string]any{"status": target} + if target == "exception" { + updates["previous_status"] = order.Status + } + if err := tx.Model(&order).Updates(updates).Error; err != nil { + return err + } + return tx.Create(gasorderStatusRecord(order.ID, order.Status, target, request.Reason, operatorIdentity, operatorName)).Error + }) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"updated": true, "status": target}) +} + +func gasorderStatusRecord(orderID uint64, from, to, reason, operatorIdentity, operatorName string) *models.GasorderStatus { + return &models.GasorderStatus{ + Entity: models.Entity{Identity: models.NewIdentity(), Status: "recorded", Version: 1}, + GasorderBasicID: orderID, FromStatus: from, ToStatus: to, + OperatorIdentity: operatorIdentity, OperatorName: operatorName, OccurredAt: time.Now(), Reason: reason, + } +} + +func getGasorderTrack(ctx *gin.Context) { + var track models.GasorderTrack + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&track).Error; err != nil { + common.RespondRecordError(ctx, err) + return + } + var points []models.GasorderTrackPoint + if err := impl.DBService.Where("gasorder_track_id = ?", track.ID).Order("occurred_at asc, id asc").Find(&points).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + response, err := common.PublicResourceResponse(gin.H{"track": track, "points": points}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, common.ProtectPreciseLocation(ctx, &models.GasorderTrackPoint{}, response)) +} + +func trimGasorderReason(value string) string { return strings.TrimSpace(value) } diff --git a/backend/api/internal/logic/platform/gasorder_workflow_test.go b/backend/api/internal/logic/platform/gasorder_workflow_test.go new file mode 100644 index 0000000..0f529cb --- /dev/null +++ b/backend/api/internal/logic/platform/gasorder_workflow_test.go @@ -0,0 +1,54 @@ +package platform + +import ( + "testing" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" +) + +func TestGasorderCreatorTypesCoverEveryConfirmedOrigin(t *testing.T) { + for _, creatorType := range []string{"user", "staff", "delivery", "gas"} { + if gasorderCreatorModels[creatorType] == nil { + t.Fatalf("creator type %q is not supported", creatorType) + } + } + if gasorderCreatorModels["ec"] != nil { + t.Fatal("commerce order creator leaked into gasorder") + } +} + +func TestGasorderStatusRecordIsImmutableSnapshot(t *testing.T) { + record := gasorderStatusRecord(7, "assigned", "filling", "start filling", "operator-a", "Operator") + if record.GasorderBasicID != 7 || record.FromStatus != "assigned" || record.ToStatus != "filling" { + t.Fatalf("unexpected status record: %#v", record) + } + if record.Status != "recorded" || record.OccurredAt.IsZero() { + t.Fatalf("status record lacks immutable metadata: %#v", record) + } +} + +func TestContractRevisionKeepsSingleContractHistory(t *testing.T) { + contract := models.GasorderContract{ + Entity: models.Entity{ID: 9, Status: "active"}, + } + revision := contractRevision(contract, "renew", "annual renewal", "operator-a", "Operator") + if revision.GasorderContractID != 9 || revision.Action != "renew" || revision.ContractStatus != "active" { + t.Fatalf("unexpected contract revision: %#v", revision) + } +} + +func TestGasorderSensitiveContactsAreMasked(t *testing.T) { + value := map[string]any{ + "contact_name": "张三", + "contact_phone": "13800138000", + "recipient_name": "李四", + "recipient_phone": "13900139000", + } + common.ProtectPublicFields(value, false, false, false) + for _, raw := range []string{"contact_name", "contact_phone", "recipient_name", "recipient_phone"} { + if _, exists := value[raw]; exists { + t.Fatalf("raw field %s remains in response: %#v", raw, value) + } + } +} diff --git a/backend/api/internal/logic/platform/access.go b/backend/api/internal/logic/platform/platform_access.go similarity index 84% rename from backend/api/internal/logic/platform/access.go rename to backend/api/internal/logic/platform/platform_access.go index 3402273..d8d27e1 100644 --- a/backend/api/internal/logic/platform/access.go +++ b/backend/api/internal/logic/platform/platform_access.go @@ -40,7 +40,8 @@ func platformMenuAllowsPath(menus []models.PlatformMenu, requestPath string) boo return false } relative := strings.Trim(requestPath[index+len(marker):], "/") - domain := strings.Split(relative, "/")[0] + resource := strings.Split(relative, "/")[0] + domain := platformRouteDomain(resource) for _, menu := range menus { if menu.MenuCode == domain { return true @@ -53,6 +54,26 @@ func platformMenuAllowsPath(menus []models.PlatformMenu, requestPath string) boo return false } +func platformRouteDomain(resource string) string { + prefix := strings.Split(resource, "_")[0] + switch prefix { + case "product": + return "device" + case "gasorder": + return "delivery" + case "fin": + return "finance" + case "cms": + return "content" + case "cs": + return "customer_service" + case "platfrom", "platform": + return "platform" + default: + return prefix + } +} + // RequirePlatformMenuAccess enforces role-menu authorization after JWT authentication. func RequirePlatformMenuAccess() gin.HandlerFunc { return func(ctx *gin.Context) { diff --git a/backend/api/internal/logic/platform/role.go b/backend/api/internal/logic/platform/platform_role_platform_menu_platfrom_account.go similarity index 82% rename from backend/api/internal/logic/platform/role.go rename to backend/api/internal/logic/platform/platform_role_platform_menu_platfrom_account.go index ba98f5e..fa34966 100644 --- a/backend/api/internal/logic/platform/role.go +++ b/backend/api/internal/logic/platform/platform_role_platform_menu_platfrom_account.go @@ -7,6 +7,7 @@ import ( "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/bsm-sdk/core/middleware" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "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" @@ -15,10 +16,10 @@ import ( var errSystemPlatformRole = errors.New("system platform roles cannot be modified") // ListPlatformRole 查询平台角色分页列表。 -func ListPlatformRole(ctx *gin.Context) { listPage[models.PlatformRole](ctx) } +func ListPlatformRole(ctx *gin.Context) { common.ListPage[models.PlatformRole](ctx) } // GetPlatformRole 查询一个平台角色。 -func GetPlatformRole(ctx *gin.Context) { getByIdentity[models.PlatformRole](ctx) } +func GetPlatformRole(ctx *gin.Context) { common.GetByIdentity[models.PlatformRole](ctx) } // CreatePlatformRole 创建非内置平台角色。 func CreatePlatformRole(ctx *gin.Context) { @@ -30,7 +31,7 @@ func CreatePlatformRole(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - request.Entity = newEntity("enabled") + request.Entity = common.NewEntity("enabled") request.IsSystem = false if request.DataScope == "" { request.DataScope = "global" @@ -39,7 +40,7 @@ func CreatePlatformRole(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, request) + common.RespondCreatedResource(ctx, request) } // UpdatePlatformRole 更新非内置平台角色。 @@ -57,14 +58,14 @@ func UpdatePlatformRole(ctx *gin.Context) { } var role models.PlatformRole if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&role).Error; err != nil { - respondRecordError(ctx, err) + common.RespondRecordError(ctx, err) return } if role.IsSystem { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.PlatformRole{}, gin.H{"name": request.Name, "data_scope": request.DataScope}, []string{"name", "data_scope"}) + common.UpdateAllowedByIdentity(ctx, &models.PlatformRole{}, gin.H{"name": request.Name, "data_scope": request.DataScope}, []string{"name", "data_scope"}) } type platformMenuRequest struct { @@ -102,7 +103,7 @@ func platformMenuViews(list []models.PlatformMenu) []platformMenuView { func GetPlatformMenu(ctx *gin.Context) { var menu models.PlatformMenu if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&menu).Error; err != nil { - respondRecordError(ctx, err) + common.RespondRecordError(ctx, err) return } list := []models.PlatformMenu{menu} @@ -125,17 +126,17 @@ func CreatePlatformMenu(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - parentID, err := resolveIdentityID(&models.PlatformMenu{}, request.ParentIdentity, false) + parentID, err := common.ResolveIdentityID(&models.PlatformMenu{}, request.ParentIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - menu := models.PlatformMenu{Entity: newEntity("enabled"), ParentID: parentID, MenuCode: request.MenuCode, Name: request.Name, Icon: request.Icon, Path: request.Path, SortNo: request.SortNo} + menu := models.PlatformMenu{Entity: common.NewEntity("enabled"), ParentID: parentID, MenuCode: request.MenuCode, Name: request.Name, Icon: request.Icon, Path: request.Path, SortNo: request.SortNo} if err := impl.DBService.Create(&menu).Error; err != nil { infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, menu) + common.RespondCreatedResource(ctx, menu) } func UpdatePlatformMenu(ctx *gin.Context) { @@ -147,26 +148,26 @@ func UpdatePlatformMenu(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - parentID, err := resolveIdentityID(&models.PlatformMenu{}, request.ParentIdentity, false) + parentID, err := common.ResolveIdentityID(&models.PlatformMenu{}, request.ParentIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.PlatformMenu{}, gin.H{"parent_id": parentID, "name": request.Name, "icon": request.Icon, "path": request.Path, "sort_no": request.SortNo}, []string{"parent_id", "name", "icon", "path", "sort_no"}) + common.UpdateAllowedByIdentity(ctx, &models.PlatformMenu{}, gin.H{"parent_id": parentID, "name": request.Name, "icon": request.Icon, "path": request.Path, "sort_no": request.SortNo}, []string{"parent_id", "name", "icon", "path", "sort_no"}) } func UpdatePlatformMenuStatus(ctx *gin.Context) { if !requirePlatformRoot(ctx) { return } - UpdateRecordStatus(ctx, &models.PlatformMenu{}) + common.UpdateRecordStatus(ctx, &models.PlatformMenu{}) } func ArchivePlatformMenu(ctx *gin.Context) { if !requirePlatformRoot(ctx) { return } - ArchiveRecord(ctx, &models.PlatformMenu{}) + common.ArchiveRecord(ctx, &models.PlatformMenu{}) } type platformRoleMenusRequest struct { @@ -232,7 +233,7 @@ func ListPlatformRoleMenuIdentities(ctx *gin.Context) { } var role models.PlatformRole if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&role).Error; err != nil { - respondRecordError(ctx, err) + common.RespondRecordError(ctx, err) return } var identities []string @@ -261,14 +262,14 @@ func UpdatePlatformRoleStatus(ctx *gin.Context) { } var role models.PlatformRole if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&role).Error; err != nil { - respondRecordError(ctx, err) + common.RespondRecordError(ctx, err) return } if role.IsSystem { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.PlatformRole{}, gin.H{"status": request.Status}, []string{"status"}) + common.UpdateAllowedByIdentity(ctx, &models.PlatformRole{}, gin.H{"status": request.Status}, []string{"status"}) } // ArchivePlatformRole 归档非内置平台角色,系统角色始终受保护。 @@ -278,14 +279,14 @@ func ArchivePlatformRole(ctx *gin.Context) { } var role models.PlatformRole if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&role).Error; err != nil { - respondRecordError(ctx, err) + common.RespondRecordError(ctx, err) return } if role.IsSystem { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.PlatformRole{}, archiveValues(), []string{"status"}) + common.UpdateAllowedByIdentity(ctx, &models.PlatformRole{}, archiveValues(), []string{"status"}) } // ListPlatformMenu 返回菜单树构建所需的有序菜单列表。 @@ -305,10 +306,10 @@ func ListPlatformMenu(ctx *gin.Context) { // ListPlatfromAccount 查询平台账号列表,手机号在展示层脱敏。 func ListPlatfromAccount(ctx *gin.Context) { - page, size := pageSize(ctx) + page, size := common.PageSize(ctx) var list []models.PlatfromAccount var total int64 - query := applyKeywordFilter(ctx, impl.DBService.Model(&models.PlatfromAccount{}), &models.PlatfromAccount{}) + query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(&models.PlatfromAccount{}), &models.PlatfromAccount{}) if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return @@ -320,7 +321,7 @@ func ListPlatfromAccount(ctx *gin.Context) { views := make([]map[string]any, 0, len(list)) for _, item := range list { view := platformAccountView(item) - protectPreciseLocation(ctx, &models.PlatfromAccount{}, view) + common.ProtectPreciseLocation(ctx, &models.PlatfromAccount{}, view) views = append(views, view) } infra.Response.Success(ctx, gin.H{"total": total, "list": views}) @@ -346,11 +347,11 @@ func platformAccountView(account models.PlatfromAccount) map[string]any { func GetPlatfromAccount(ctx *gin.Context) { var account models.PlatfromAccount if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&account).Error; err != nil { - respondRecordError(ctx, err) + common.RespondRecordError(ctx, err) return } view := platformAccountView(account) - infra.Response.Success(ctx, protectPreciseLocation(ctx, &models.PlatfromAccount{}, view)) + infra.Response.Success(ctx, common.ProtectPreciseLocation(ctx, &models.PlatfromAccount{}, view)) } func CreatePlatfromAccount(ctx *gin.Context) { @@ -371,13 +372,13 @@ func CreatePlatfromAccount(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - account := models.PlatfromAccount{Entity: newEntity("enabled"), Username: request.Username, DisplayName: request.DisplayName, Avatar: request.Avatar, PasswordHash: hash, PlatformRoleCode: request.PlatformRoleCode, Phone: request.Phone} + account := models.PlatfromAccount{Entity: common.NewEntity("enabled"), Username: request.Username, DisplayName: request.DisplayName, Avatar: request.Avatar, PasswordHash: hash, PlatformRoleCode: request.PlatformRoleCode, Phone: request.Phone} if err := impl.DBService.Create(&account).Error; err != nil { infra.Response.Error(ctx, err) return } view := platformAccountView(account) - infra.Response.Success(ctx, protectPreciseLocation(ctx, &models.PlatfromAccount{}, view)) + infra.Response.Success(ctx, common.ProtectPreciseLocation(ctx, &models.PlatfromAccount{}, view)) } func UpdatePlatfromAccount(ctx *gin.Context) { @@ -402,7 +403,7 @@ func UpdatePlatfromAccount(ctx *gin.Context) { } values["platform_role_code"] = *request.PlatformRoleCode } - updateAllowedByIdentity(ctx, &models.PlatfromAccount{}, values, []string{"display_name", "avatar", "platform_role_code", "phone"}) + common.UpdateAllowedByIdentity(ctx, &models.PlatfromAccount{}, values, []string{"display_name", "avatar", "platform_role_code", "phone"}) } func isAssignablePlatformRole(roleCode string) bool { diff --git a/backend/api/internal/logic/platform/product.go b/backend/api/internal/logic/platform/product_workflow.go similarity index 82% rename from backend/api/internal/logic/platform/product.go rename to backend/api/internal/logic/platform/product_workflow.go index 65c76ff..1953426 100644 --- a/backend/api/internal/logic/platform/product.go +++ b/backend/api/internal/logic/platform/product_workflow.go @@ -10,6 +10,7 @@ import ( "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/bsm-sdk/core/middleware" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "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" @@ -23,16 +24,16 @@ var productLifecycleStatuses = map[string]bool{ "pending": true, "in_stock": true, "in_transit": true, "in_use": true, "repairing": true, "scrapped": true, } -func ProductInfoHandlers(relations ...ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { +func ProductInfoHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { fields := []string{"code", "name", "params", "produced_at", "is_enabled", "status", "action", "reason", "remark"} - return func(ctx *gin.Context) { listResource(ctx, &models.ProductInfo{}) }, + return func(ctx *gin.Context) { common.ListResource(ctx, &models.ProductInfo{}) }, func(ctx *gin.Context) { createProductInfo(ctx, fields, relations) }, - func(ctx *gin.Context) { getResource(ctx, &models.ProductInfo{}) }, + func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductInfo{}) }, func(ctx *gin.Context) { updateProductInfo(ctx, fields, relations) } } -func createProductInfo(ctx *gin.Context, fields []string, relations []ResourceRelation) { - values, err := prepareResourceValues(ctx, &models.ProductInfo{}, fields, relations) +func createProductInfo(ctx *gin.Context, fields []string, relations []common.ResourceRelation) { + values, err := common.PrepareResourceValues(ctx, &models.ProductInfo{}, fields, relations) if err != nil || !validParamsText(values["params"]) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -40,7 +41,7 @@ func createProductInfo(ctx *gin.Context, fields []string, relations []ResourceRe delete(values, "action") delete(values, "reason") delete(values, "remark") - data := models.ProductInfo{Entity: newEntity("pending"), Params: "{}", IsEnabled: false} + data := models.ProductInfo{Entity: common.NewEntity("pending"), Params: "{}", IsEnabled: false} if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || data.ProducedAt.IsZero() { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -62,16 +63,16 @@ func createProductInfo(ctx *gin.Context, fields []string, relations []ResourceRe infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, &data) + common.RespondCreatedResource(ctx, &data) } -func updateProductInfo(ctx *gin.Context, fields []string, relations []ResourceRelation) { +func updateProductInfo(ctx *gin.Context, fields []string, relations []common.ResourceRelation) { var input map[string]any if err := ctx.ShouldBindJSON(&input); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - values, err := resolveResourceRelations(input, fields, relations, false) + values, err := common.ResolveResourceRelations(input, fields, relations, false) if err != nil || len(values) == 0 || !validParamsText(values["params"]) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -124,24 +125,24 @@ func updateProductInfo(ctx *gin.Context, fields []string, relations []ResourceRe return tx.Create(newProductOwner(current, action, reason, remark, operatorIdentity, operatorName, time.Now())).Error }) if err != nil { - respondRecordError(ctx, err) + common.RespondRecordError(ctx, err) return } infra.Response.Success(ctx, gin.H{"updated": true}) } -func ProductOwnerHandlers(relations ...ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { +func ProductOwnerHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { fields := []string{"action", "occurred_at", "reason", "remark"} return listProductOwners, func(ctx *gin.Context) { createProductOwner(ctx, fields, relations) }, - func(ctx *gin.Context) { getResource(ctx, &models.ProductOwner{}) } + func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductOwner{}) } } func listProductOwners(ctx *gin.Context) { - page, size := pageSize(ctx) + page, size := common.PageSize(ctx) var list []models.ProductOwner var total int64 - query := applyKeywordFilter(ctx, impl.DBService.Model(&models.ProductOwner{}), &models.ProductOwner{}) + query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(&models.ProductOwner{}), &models.ProductOwner{}) if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return @@ -150,7 +151,7 @@ func listProductOwners(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - response, err := publicResourceResponse(list) + response, err := common.PublicResourceResponse(list) if err != nil { infra.Response.Error(ctx, err) return @@ -166,24 +167,24 @@ func UpdateProductInfoStatus(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.ProductInfo{}, gin.H{"status": request.Status}, []string{"status"}) + common.UpdateAllowedByIdentity(ctx, &models.ProductInfo{}, gin.H{"status": request.Status}, []string{"status"}) } -func ProductRepairHandlers(relations ...ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { +func ProductRepairHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { fields := []string{"repair_no", "repair_type", "started_at", "completed_at", "result", "target_status", "content", "operator", "remark"} - return func(ctx *gin.Context) { listResource(ctx, &models.ProductRepair{}) }, + return func(ctx *gin.Context) { common.ListResource(ctx, &models.ProductRepair{}) }, func(ctx *gin.Context) { createProductRepair(ctx, fields, relations) }, - func(ctx *gin.Context) { getResource(ctx, &models.ProductRepair{}) }, + func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductRepair{}) }, func(ctx *gin.Context) { updateProductRepair(ctx, fields, relations) } } -func createProductRepair(ctx *gin.Context, fields []string, relations []ResourceRelation) { - values, err := prepareResourceValues(ctx, &models.ProductRepair{}, fields, relations) +func createProductRepair(ctx *gin.Context, fields []string, relations []common.ResourceRelation) { + values, err := common.PrepareResourceValues(ctx, &models.ProductRepair{}, fields, relations) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - data := models.ProductRepair{Entity: newEntity("active"), Result: "pending"} + data := models.ProductRepair{Entity: common.NewEntity("active"), Result: "pending"} if err := decodeValues(values, &data); err != nil || data.Result != "pending" || !validRepair(data) || data.ProductInfoID == 0 || data.RepairNo == "" { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -198,16 +199,16 @@ func createProductRepair(ctx *gin.Context, fields []string, relations []Resource infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, &data) + common.RespondCreatedResource(ctx, &data) } -func updateProductRepair(ctx *gin.Context, fields []string, relations []ResourceRelation) { +func updateProductRepair(ctx *gin.Context, fields []string, relations []common.ResourceRelation) { var input map[string]any if err := ctx.ShouldBindJSON(&input); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - values, err := resolveResourceRelations(input, fields, relations, false) + values, err := common.ResolveResourceRelations(input, fields, relations, false) if err != nil || len(values) == 0 { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -238,7 +239,7 @@ func updateProductRepair(ctx *gin.Context, fields []string, relations []Resource return nil }) if err != nil { - respondRecordError(ctx, err) + common.RespondRecordError(ctx, err) return } infra.Response.Success(ctx, gin.H{"updated": true}) @@ -259,8 +260,8 @@ func validRepair(repair models.ProductRepair) bool { } } -func createProductOwner(ctx *gin.Context, fields []string, relations []ResourceRelation) { - values, err := prepareResourceValues(ctx, &models.ProductOwner{}, fields, relations) +func createProductOwner(ctx *gin.Context, fields []string, relations []common.ResourceRelation) { + values, err := common.PrepareResourceValues(ctx, &models.ProductOwner{}, fields, relations) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -270,7 +271,7 @@ func createProductOwner(ctx *gin.Context, fields []string, relations []ResourceR infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - data := models.ProductOwner{Entity: newEntity("recorded")} + data := models.ProductOwner{Entity: common.NewEntity("recorded")} if err := decodeValues(values, &data); err != nil || data.ProductInfoID == 0 || data.OccurredAt.IsZero() { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -280,7 +281,7 @@ func createProductOwner(ctx *gin.Context, fields []string, relations []ResourceR infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, &data) + common.RespondCreatedResource(ctx, &data) } func validParamsText(value any) bool { diff --git a/backend/api/internal/logic/platform/product_test.go b/backend/api/internal/logic/platform/product_workflow_test.go similarity index 100% rename from backend/api/internal/logic/platform/product_test.go rename to backend/api/internal/logic/platform/product_workflow_test.go diff --git a/backend/api/internal/logic/platform/resource.go b/backend/api/internal/logic/platform/resource_contract.go similarity index 83% rename from backend/api/internal/logic/platform/resource.go rename to backend/api/internal/logic/platform/resource_contract.go index 328e064..daaa7ef 100644 --- a/backend/api/internal/logic/platform/resource.go +++ b/backend/api/internal/logic/platform/resource_contract.go @@ -14,6 +14,7 @@ const ( ReadOnly ResourceMode = "readonly" AppendOnly ResourceMode = "append_only" Editable ResourceMode = "editable" + Managed ResourceMode = "managed" ) // ResourceContract is the expected cross-layer representation of one resource. @@ -44,6 +45,8 @@ func (d ResourceDefinition) Allows(method string) bool { return method == http.MethodGet || method == http.MethodPost case Editable: return method == http.MethodGet || method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch + case Managed: + return method == http.MethodGet || method == http.MethodPost || method == http.MethodPut case Writable: return method == http.MethodGet || method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete default: @@ -79,7 +82,9 @@ func ExpectedResources() []ResourceContract { resourceContract("user", "user_account", Writable, "list"), resourceContract("user", "user_address", Writable, "list"), resourceContract("user", "user_service_relation", Writable, "list"), resourceContract("product", "product_type", Editable, "list"), resourceContract("product", "product_warehouse", Editable, "list"), resourceContract("product", "product_info", Editable, "list"), resourceContract("product", "product_repair", Editable, "list"), resourceContract("product", "product_owner", 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", ReadOnly, "list"), + resourceContract("gasorder", "gasorder_contract", Managed, "list"), resourceContract("gasorder", "gasorder_contract_product", AppendOnly, "list"), resourceContract("gasorder", "gasorder_contract_revision", ReadOnly, "list"), + resourceContract("gasorder", "gasorder_basic", AppendOnly, "list"), resourceContract("gasorder", "gasorder_item", ReadOnly, "list"), resourceContract("gasorder", "gasorder_assign", ReadOnly, "list"), resourceContract("gasorder", "gasorder_status", ReadOnly, "list"), + resourceContract("gasorder", "gasorder_track", ReadOnly, "list"), resourceContract("gasorder", "gasorder_track_point", ReadOnly, "list"), resourceContract("gasorder", "gasorder_confirm", ReadOnly, "list"), resourceContract("gasorder", "gasorder_payment", ReadOnly, "list"), resourceContract("finance", "fin_payment", Writable, "list"), resourceContract("finance", "fin_settlement", Writable, "list"), resourceContract("finance", "fin_reconciliation", Writable, "list"), resourceContract("content", "cms_content", 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"), @@ -92,21 +97,5 @@ func resourceContract(domain, name string, mode ResourceMode, pageKind string) R } func resourcePath(domain, name string) string { - if domain == "product" || domain == "wallet" { - return "/" + name - } - switch name { - case "staff_account": - return "/staff/account" - case "staff_credential": - return "/staff/credential" - case "user_account": - return "/user/account" - case "user_address": - return "/user/address" - case "user_service_relation": - return "/user/service_relation" - default: - return "/" + domain + "/" + name - } + return "/" + name } diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go deleted file mode 100644 index dd4d151..0000000 --- a/backend/api/internal/logic/platform/resource_test.go +++ /dev/null @@ -1,894 +0,0 @@ -package platform - -import ( - "bytes" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "reflect" - "regexp" - "strings" - "testing" - "time" - - "git.apinb.com/bsm-sdk/core/errcode" - "git.apinb.com/bsm-sdk/core/types" - "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" - "git.apinb.com/heqiapp/platforms/backend/api/internal/models" - "github.com/DATA-DOG/go-sqlmock" - "github.com/gin-gonic/gin" - "google.golang.org/grpc/status" - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -func TestExpectedResources(t *testing.T) { - assertContract(t, ExpectedResources(), "gas", "gas_basic", Writable, "list") - assertContract(t, ExpectedResources(), "ec", "ec_order_item", Writable, "list") - assertContract(t, ExpectedResources(), "wallet", "wallet_record", ReadOnly, "list") - assertContract(t, ExpectedResources(), "delivery", "delivery_track_point", ReadOnly, "list") - assertContract(t, ExpectedResources(), "product", "product_info", Editable, "list") - assertContract(t, ExpectedResources(), "product", "product_owner", AppendOnly, "list") -} - -func TestContentUsesCMSResourceAndRemovedDomainsAreAbsent(t *testing.T) { - resources := ExpectedResources() - assertContract(t, resources, "content", "cms_content", Writable, "list") - for _, removed := range []string{"ntf_template", "report", "report_item", "report_metric_snapshot", "audit_operation_log", "audit_export_log", "audit_approval"} { - for _, resource := range resources { - if resource.Name == removed { - t.Fatalf("removed resource %s remains registered", removed) - } - } - } -} - -func TestSafetyResourcesAreAbsent(t *testing.T) { - removed := map[string]struct{}{ - "safe_rule": {}, "safe_event": {}, "safe_inspection": {}, "safe_event_disposal": {}, - } - for _, contract := range ExpectedResources() { - if _, exists := removed[contract.Name]; exists || contract.Domain == "safety" { - t.Fatalf("removed safety resource %s/%s remains registered", contract.Domain, contract.Name) - } - } -} - -func TestResourceDefinitionAllowsOnlySupportedMethods(t *testing.T) { - if (ResourceDefinition{Mode: ReadOnly}).Allows(http.MethodPost) { - t.Fatal("readonly allows POST") - } - if !(ResourceDefinition{Mode: AppendOnly}).Allows(http.MethodPost) { - t.Fatal("append-only rejects POST") - } - if (ResourceDefinition{Mode: AppendOnly}).Allows(http.MethodDelete) { - t.Fatal("append-only allows DELETE") - } -} - -func TestArchiveValuesOnlyArchives(t *testing.T) { - got := archiveValues() - if len(got) != 1 || got["status"] != "archived" { - t.Fatalf("unexpected archive values: %#v", got) - } -} - -func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) { - got := filterFields(map[string]any{"name": "n", "password_hash": "x"}, []string{"name"}) - if len(got) != 1 || got["name"] != "n" { - t.Fatalf("unexpected filtered fields: %#v", got) - } -} - -func TestResourceDefinitionAllowsOnlyMethodsForEachMode(t *testing.T) { - tests := []struct { - name string - mode ResourceMode - method string - want bool - }{ - {"readonly GET", ReadOnly, http.MethodGet, true}, - {"readonly POST", ReadOnly, http.MethodPost, false}, - {"append-only GET", AppendOnly, http.MethodGet, true}, - {"append-only POST", AppendOnly, http.MethodPost, true}, - {"append-only PUT", AppendOnly, http.MethodPut, false}, - {"editable GET", Editable, http.MethodGet, true}, - {"editable POST", Editable, http.MethodPost, true}, - {"editable PUT", Editable, http.MethodPut, true}, - {"editable PATCH", Editable, http.MethodPatch, true}, - {"editable DELETE", Editable, http.MethodDelete, false}, - {"writable GET", Writable, http.MethodGet, true}, - {"writable POST", Writable, http.MethodPost, true}, - {"writable PUT", Writable, http.MethodPut, true}, - {"writable PATCH", Writable, http.MethodPatch, true}, - {"writable DELETE", Writable, http.MethodDelete, true}, - {"writable OPTIONS", Writable, http.MethodOptions, false}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if got := (ResourceDefinition{Mode: test.mode}).Allows(test.method); got != test.want { - t.Fatalf("Allows(%s) = %t, want %t", test.method, got, test.want) - } - }) - } -} - -func TestUpdateAllowedByIdentityFiltersUnknownFieldsAndUsesIdentity(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectBegin() - mock.ExpectExec(regexp.QuoteMeta(`UPDATE "platform_role" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)). - WithArgs("disabled", sqlmock.AnyArg(), "role-a"). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectCommit() - - ctx, recorder := updateContext(http.MethodPatch, "/roles/role-a", "role-a", nil) - updateAllowedByIdentity(ctx, &models.PlatformRole{}, gin.H{"status": "disabled", "is_system": true}, []string{"status"}) - assertResponseCode(t, recorder, 0) - assertMockExpectations(t, mock) -} - -func TestUpdateAllowedByIdentityReturnsNotFoundForZeroRows(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectBegin() - mock.ExpectExec(regexp.QuoteMeta(`UPDATE "platform_role" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)). - WithArgs("disabled", sqlmock.AnyArg(), "missing"). - WillReturnResult(sqlmock.NewResult(0, 0)) - mock.ExpectCommit() - ctx, recorder := updateContext(http.MethodPatch, "/roles/missing", "missing", nil) - - updateAllowedByIdentity(ctx, &models.PlatformRole{}, gin.H{"status": "disabled"}, []string{"status"}) - - assertResponseCode(t, recorder, int32(status.Code(errcode.ErrRecordNotFound))) - assertMockExpectations(t, mock) -} - -func TestUpdateAllowedByIdentityReturnsUniformResponseForDatabaseError(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectBegin() - mock.ExpectExec(regexp.QuoteMeta(`UPDATE "platform_role" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)). - WithArgs("disabled", sqlmock.AnyArg(), "role-a"). - WillReturnError(errors.New("database unavailable")) - mock.ExpectRollback() - - ctx, recorder := updateContext(http.MethodPatch, "/roles/role-a", "role-a", nil) - updateAllowedByIdentity(ctx, &models.PlatformRole{}, gin.H{"status": "disabled"}, []string{"status"}) - - var reply responseBody - if err := json.Unmarshal(recorder.Body.Bytes(), &reply); err != nil { - t.Fatal(err) - } - if reply.Code != 500 || reply.Message == "" || string(reply.Details) != `""` { - t.Fatalf("database error did not use uniform response: %s", recorder.Body.String()) - } -} - -func TestPlatformRoleStatusAndArchiveReturnNotFoundWhenUpdateAffectsZeroRows(t *testing.T) { - for _, test := range []struct { - name string - handler gin.HandlerFunc - method string - body string - status string - }{ - {"status", UpdatePlatformRoleStatus, http.MethodPatch, `{"status":"disabled"}`, "disabled"}, - {"archive", ArchivePlatformRole, http.MethodDelete, "", "archived"}, - } { - t.Run(test.name, func(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE identity = $1 ORDER BY "platform_role"."id" LIMIT $2`)). - WithArgs("role-a", 1). - WillReturnRows(platformRoleRows("role-a")) - mock.ExpectBegin() - mock.ExpectExec(regexp.QuoteMeta(`UPDATE "platform_role" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)). - WithArgs(test.status, sqlmock.AnyArg(), "role-a"). - WillReturnResult(sqlmock.NewResult(0, 0)) - mock.ExpectCommit() - - ctx, recorder := updateContext(test.method, "/roles/role-a", "role-a", []byte(test.body)) - ctx.Set("Auth", &types.JwtClaims{Role: "root"}) - test.handler(ctx) - - assertResponseCode(t, recorder, int32(status.Code(errcode.ErrRecordNotFound))) - assertMockExpectations(t, mock) - }) - } -} - -func TestGetEcOrderReturnsOrderItems(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)). - WithArgs("order-a", 1). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "order_no", "user_account_id", "gas_station_id", "delivery_point_id", "total_amount"}). - AddRow(uint64(1), "order-a", nil, nil, "enabled", 1, "O-1", uint64(2), uint64(3), uint64(4), int64(500))) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "ec_order_item" WHERE ec_order_id = $1 ORDER BY id asc`)). - WithArgs(uint64(1)). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "ec_order_id", "ec_product_id", "product_snapshot", "quantity", "sale_amount"}). - AddRow(uint64(2), "item-a", nil, nil, "enabled", 1, uint64(1), uint64(5), `{}`, 2, int64(500))) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "delivery_basic" WHERE id IN ($1)`)). - WithArgs(uint64(4)). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(4), "delivery-a")) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "ec_order" WHERE id IN ($1)`)). - WithArgs(uint64(1)). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(1), "order-a")) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "ec_product" WHERE id IN ($1)`)). - WithArgs(uint64(5)). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(5), "product-a")) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1)`)). - WithArgs(uint64(3)). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(3), "gas-a")) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "user_account" WHERE id IN ($1)`)). - WithArgs(uint64(2)). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(2), "user-a")) - - ctx, recorder := updateContext(http.MethodGet, "/ec/ec_order/order-a", "order-a", nil) - GetEcOrder(ctx) - - assertResponseCode(t, recorder, 0) - if !strings.Contains(recorder.Body.String(), `"items"`) || !strings.Contains(recorder.Body.String(), `"item-a"`) || strings.Contains(recorder.Body.String(), `_id"`) { - t.Fatalf("order detail omitted its items: %s", recorder.Body.String()) - } - assertMockExpectations(t, mock) -} - -func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalidPayloads(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)). - WithArgs("order-a", 1). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1))) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_product" WHERE identity = $1 ORDER BY "ec_product"."id" LIMIT $2`)). - WithArgs("product-a", 1). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2))) - - ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","quantity":2}`)) - values, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"quantity"}, []ResourceRelation{ - {Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}, - {Input: "ec_product_identity", Column: "ec_product_id", Model: &models.EcProduct{}, Required: true}, - }) - if err != nil { - t.Fatal(err) - } - if values["ec_order_id"] != uint64(1) || values["ec_product_id"] != uint64(2) || values["quantity"] != float64(2) { - t.Fatalf("unexpected resolved values: %#v", values) - } - assertMockExpectations(t, mock) - - for _, body := range []string{`{}`, `{"ec_order_id":1}`, `{"quantity":2}`} { - ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(body)) - if _, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"quantity"}, []ResourceRelation{{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}}); err == nil { - t.Fatalf("payload %s was accepted", body) - } - } -} - -func TestPrepareResourceValuesAcceptsArbitraryTextFields(t *testing.T) { - t.Run("order item", func(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)). - WithArgs("order-a", 1). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1))) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_product" WHERE identity = $1 ORDER BY "ec_product"."id" LIMIT $2`)). - WithArgs("product-a", 1). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2))) - - ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","product_snapshot":"arbitrary product snapshot text","quantity":2,"sale_amount":500}`)) - values, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"product_snapshot", "quantity", "sale_amount"}, []ResourceRelation{ - {Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}, - {Input: "ec_product_identity", Column: "ec_product_id", Model: &models.EcProduct{}, Required: true}, - }) - if err != nil { - t.Fatal(err) - } - if values["product_snapshot"] != `arbitrary product snapshot text` { - t.Fatalf("product snapshot was not normalized to a string: %#v", values) - } - assertMockExpectations(t, mock) - }) - -} - -func TestCreateGasAccountResolvesGasBasicIdentityBeforePersisting(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "gas_basic" WHERE identity = $1 ORDER BY "gas_basic"."id" LIMIT $2`)). - WithArgs("gas-a", 1). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(8))) - mock.ExpectBegin() - 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) -} - -func TestKeywordColumnsUseSafeTextAllowlist(t *testing.T) { - tests := []struct { - name string - model any - want []string - }{ - {"gas basic excludes sensitive fields", &models.GasBasic{}, []string{"code", "name"}}, - {"user address has no searchable safe text", &models.UserAddress{}, []string{}}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got := keywordColumns(test.model) - if !reflect.DeepEqual(got, test.want) { - t.Fatalf("keywordColumns(%T) = %#v, want %#v", test.model, got, test.want) - } - }) - } -} - -func TestListPlatformMenuReturnsParentIdentityWithoutParentID(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_menu" ORDER BY sort_no asc, id asc`)). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "parent_id", "menu_code", "name", "icon", "path", "sort_no"}). - AddRow(uint64(1), "root-a", nil, nil, "enabled", 1, uint64(0), "root", "Root", "", "", 1). - AddRow(uint64(2), "child-a", nil, nil, "enabled", 1, uint64(1), "child", "Child", "", "/child", 2)) - - ctx, recorder := updateContext(http.MethodGet, "/platform/platform_menu", "", nil) - ctx.Set("Auth", &types.JwtClaims{Role: "root"}) - ListPlatformMenu(ctx) - - assertResponseCode(t, recorder, 0) - if !strings.Contains(recorder.Body.String(), `"parent_identity":"root-a"`) || strings.Contains(recorder.Body.String(), `"parent_id"`) { - t.Fatalf("menu response leaked parent_id or omitted identity: %s", recorder.Body.String()) - } - assertMockExpectations(t, mock) -} - -func TestListPlatformMenuReturnsOnlyMenusAssignedToNonRootRole(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE role_code = $1 AND status = $2 ORDER BY "platform_role"."id" LIMIT $3`)). - WithArgs("finance_operator", "enabled", 1). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "role_code", "name", "data_scope", "is_system"}). - AddRow(uint64(7), "finance-role", nil, nil, "enabled", 1, "finance_operator", "Finance", "global", false)) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT platform_menu.* FROM "platform_menu" JOIN platform_role_menu_relation ON platform_role_menu_relation.platform_menu_id = platform_menu.id WHERE platform_role_menu_relation.platform_role_id = $1 AND platform_menu.status = $2 ORDER BY sort_no asc, id asc`)). - WithArgs(uint64(7), "enabled"). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "parent_id", "menu_code", "name", "icon", "path", "sort_no"}). - AddRow(uint64(9), "finance-menu", nil, nil, "enabled", 1, uint64(0), "finance", "Finance", "", "/finance/payment", 1)) - - ctx, recorder := updateContext(http.MethodGet, "/platform/platform_menu", "", nil) - ctx.Set("Auth", &types.JwtClaims{Role: "finance_operator"}) - ListPlatformMenu(ctx) - - assertResponseCode(t, recorder, 0) - body := recorder.Body.String() - if !strings.Contains(body, `"identity":"finance-menu"`) || strings.Contains(body, `"gas-menu"`) { - t.Fatalf("non-root menu response was not constrained: %s", body) - } - assertMockExpectations(t, mock) -} - -func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) { - response := resourceResponse(map[string]any{"identity": "item-a", "id": uint64(1), "ec_order_id": uint64(2), "ec_product_id": uint64(3), "items": []any{map[string]any{"identity": "child-a", "delivery_task_id": uint64(4)}}}) - encoded, err := json.Marshal(response) - if err != nil { - t.Fatal(err) - } - for _, forbidden := range []string{`"id"`, `"ec_order_id"`, `"ec_product_id"`, `"delivery_task_id"`} { - if strings.Contains(string(encoded), forbidden) { - t.Fatalf("response exposed internal relation key %s: %s", forbidden, encoded) - } - } -} - -func TestCreatedResourceResponseUsesSafeAllowlist(t *testing.T) { - response := maskCreatedSensitiveFields(map[string]any{ - "identity": "address-a", - "user_account_identity": "user-a", - "status": "draft", - "version": float64(1), - "phone": "13800138000", - "real_name": "张三", - "credential_no": "CERT-123456", - "address": "敏感详细地址", - "principal": "负责人", - "credit_code": "CREDIT-123", - "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":"address-a"`) || !strings.Contains(body, `"user_account_identity":"user-a"`) || !strings.Contains(body, `"status":"draft"`) { - t.Fatalf("created response omitted safe public fields: %s", body) - } - for _, forbidden := range []string{`"phone"`, `"phone_masked"`, `"real_name"`, `"credential_no"`, `"address"`, `"principal"`, `"credit_code"`, `"longitude"`, `"latitude"`, "敏感详细地址", "负责人", "CREDIT-123", "120.123456", "30.456789"} { - if strings.Contains(body, forbidden) { - t.Fatalf("created response exposed sensitive field %s: %s", forbidden, body) - } - } -} - -func TestDefaultResourceResponseMasksPIIAndCoordinates(t *testing.T) { - ctx, _ := updateContext(http.MethodGet, "/user/account/user-a", "user-a", nil) - response := protectPreciseLocation(ctx, &models.UserAccount{}, map[string]any{ - "identity": "user-a", - "name": "张三", - "phone": "13800138000", - "avatar": "https://private.example/avatar.png", - "address": "敏感详细地址", - "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"`) || !strings.Contains(body, `"name_masked"`) { - t.Fatalf("default response omitted safe identity or masked PII: %s", body) - } - for _, forbidden := range []string{`"phone":`, `"name":`, `"avatar":`, `"address":`, `"longitude":"120.123456"`, `"latitude":"30.456789"`, "张三", "敏感详细地址", "private.example"} { - if strings.Contains(body, forbidden) { - t.Fatalf("default response leaked %s: %s", forbidden, body) - } - } -} - -func TestExplicitPreciseScopeRetainsCoordinatesButStillMasksPII(t *testing.T) { - ctx, _ := updateContext(http.MethodGet, "/user/address/address-a", "address-a", nil) - ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}}) - response := protectPreciseLocation(ctx, &models.UserAddress{}, map[string]any{ - "identity": "address-a", - "address": "敏感详细地址", - "longitude": "120.123456", - "latitude": "30.456789", - }) - encoded, err := json.Marshal(response) - if err != nil { - t.Fatal(err) - } - body := string(encoded) - if !strings.Contains(body, `"longitude":"120.123456"`) || !strings.Contains(body, `"latitude":"30.456789"`) { - t.Fatalf("authorized response omitted precise coordinates: %s", body) - } - if strings.Contains(body, `"address":`) || strings.Contains(body, "敏感详细地址") { - t.Fatalf("precise location scope leaked address PII: %s", body) - } -} - -func TestPublicResponseProjectionRemovesCredentialAndAttachmentSecrets(t *testing.T) { - ctx, _ := updateContext(http.MethodGet, "/staff/credential/credential-a", "credential-a", nil) - ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}}) - response := protectPreciseLocation(ctx, &models.StaffCredential{}, map[string]any{ - "identity": "credential-a", - "credential_type": "installer", - "credential_no": "CERT-123456", - "evidence_uri": "private://evidence", - "file_uri": "private://file", - "attachment_uri": "private://attachment", - }) - encoded, err := json.Marshal(response) - if err != nil { - t.Fatal(err) - } - body := string(encoded) - if !strings.Contains(body, `"identity":"credential-a"`) || !strings.Contains(body, `"credential_type":"installer"`) { - t.Fatalf("safe credential fields were removed: %s", body) - } - for _, forbidden := range []string{"credential_no", "evidence_uri", "file_uri", "attachment_uri", "CERT-123456", "private://"} { - if strings.Contains(body, forbidden) { - t.Fatalf("credential response leaked %s: %s", forbidden, body) - } - } -} - -func TestPlatformAccountDetailMasksDisplayNameAndAvatarWithPreciseScope(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platfrom_account" WHERE identity = $1 ORDER BY "platfrom_account"."id" LIMIT $2`)). - WithArgs("account-a", 1). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "username", "display_name", "avatar", "password_hash", "platform_role_code", "phone"}). - AddRow(uint64(1), "account-a", nil, nil, "enabled", 1, "operator", "张三", "private://avatar", "hash", "finance_operator", "13800138000")) - - ctx, recorder := updateContext(http.MethodGet, "/platform/platfrom_account/account-a", "account-a", nil) - ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}}) - GetPlatfromAccount(ctx) - - assertResponseCode(t, recorder, 0) - body := recorder.Body.String() - if !strings.Contains(body, `"display_name_masked"`) || !strings.Contains(body, `"phone_masked":"138****8000"`) { - t.Fatalf("platform account detail omitted masked PII: %s", body) - } - for _, forbidden := range []string{`"display_name":`, `"avatar":`, "张三", "private://avatar"} { - if strings.Contains(body, forbidden) { - t.Fatalf("platform account detail leaked %s: %s", forbidden, body) - } - } - assertMockExpectations(t, mock) -} - -func TestPlatformAccountListMasksDisplayNameAndAvatar(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "platfrom_account"`)). - WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platfrom_account" ORDER BY created_at desc LIMIT $1`)). - WithArgs(20). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "username", "display_name", "avatar", "password_hash", "platform_role_code", "phone"}). - AddRow(uint64(1), "account-a", nil, nil, "enabled", 1, "operator", "张三", "private://avatar", "hash", "finance_operator", "13800138000")) - - ctx, recorder := updateContext(http.MethodGet, "/platform/platfrom_account", "", nil) - ListPlatfromAccount(ctx) - - assertResponseCode(t, recorder, 0) - body := recorder.Body.String() - if !strings.Contains(body, `"display_name_masked"`) || strings.Contains(body, `"display_name":`) || strings.Contains(body, `"avatar":`) { - t.Fatalf("platform account list did not apply the masked projection: %s", body) - } - assertMockExpectations(t, mock) -} - -func TestNonRootCannotManagePlatformRolesOrMenus(t *testing.T) { - tests := []struct { - name string - handler gin.HandlerFunc - method string - target string - identity string - body string - }{ - {"create role", CreatePlatformRole, http.MethodPost, "/platform/platform_role", "", `{"role_code":"auditor","name":"Auditor"}`}, - {"create menu", CreatePlatformMenu, http.MethodPost, "/platform/platform_menu", "", `{"menu_code":"audit","name":"Audit","path":"/audit"}`}, - {"update menu status", UpdatePlatformMenuStatus, http.MethodPatch, "/platform/platform_menu/menu-a/status", "menu-a", `{"status":"disabled"}`}, - {"archive menu", ArchivePlatformMenu, http.MethodDelete, "/platform/platform_menu/menu-a", "menu-a", ``}, - {"replace role menus", ReplacePlatformRoleMenus, http.MethodPut, "/platform/platform_role/role-a/menu", "role-a", `{"menu_identities":[]}`}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - ctx, recorder := updateContext(test.method, test.target, test.identity, []byte(test.body)) - ctx.Set("Auth", &types.JwtClaims{Role: "platform_operator"}) - - test.handler(ctx) - - assertResponseCode(t, recorder, int32(status.Code(errcode.ErrPermissionDenied))) - assertMockExpectations(t, mock) - }) - } -} - -func TestPlatformMenuAllowsOnlyAssignedDomain(t *testing.T) { - menus := []models.PlatformMenu{ - {MenuCode: "finance", Path: "/finance"}, - {MenuCode: "fin_payment", Path: "/finance/fin-payment"}, - } - if !platformMenuAllowsPath(menus, "/heqi/platform/v1/finance/fin_payment") { - t.Fatal("assigned finance domain should be allowed") - } - if platformMenuAllowsPath(menus, "/heqi/platform/v1/user/account") { - t.Fatal("unassigned user domain should be denied") - } -} - -func TestCreatePlatformAccountRequiresAssignableNonRootRole(t *testing.T) { - tests := []struct { - name string - body string - }{ - {"missing role", `{"username":"operator","password":"secure-password"}`}, - {"root role", `{"username":"operator","password":"secure-password","platform_role_code":"root"}`}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - ctx, recorder := updateContext(http.MethodPost, "/platform/platfrom_account", "", []byte(test.body)) - ctx.Set("Auth", &types.JwtClaims{Role: "root"}) - - CreatePlatfromAccount(ctx) - - assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument))) - assertMockExpectations(t, mock) - }) - } -} - -func TestNonRootCannotAssignPlatformAccountRole(t *testing.T) { - tests := []struct { - name string - handler gin.HandlerFunc - method string - identity string - body string - }{ - {"create account", CreatePlatfromAccount, http.MethodPost, "", `{"username":"operator","password":"secure-password","platform_role_code":"auditor"}`}, - {"change account role", UpdatePlatfromAccount, http.MethodPut, "account-a", `{"platform_role_code":"auditor"}`}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - ctx, recorder := updateContext(test.method, "/platform/platfrom_account/"+test.identity, test.identity, []byte(test.body)) - ctx.Set("Auth", &types.JwtClaims{Role: "platform_operator"}) - - test.handler(ctx) - - assertResponseCode(t, recorder, int32(status.Code(errcode.ErrPermissionDenied))) - assertMockExpectations(t, mock) - }) - } -} - -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 "id","identity" FROM "gas_basic" WHERE id IN ($1)`)). - WithArgs(uint64(7)). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "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 TestListGasAccountPreloadsRelationIdentitiesInOneQuery(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`)). - WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) - 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-a", "Operator A", "hash", "admin"). - AddRow(uint64(10), "account-b", nil, nil, "enabled", 1, uint64(8), "operator-b", "Operator B", "hash", "admin")) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1,$2)`)). - WithArgs(uint64(7), uint64(8)). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "gas-a").AddRow(uint64(8), "gas-b")) - - 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_identity":"gas-b"`) { - t.Fatalf("account list omitted preloaded relation identities: %s", body) - } - assertMockExpectations(t, mock) -} - -func TestListGasAccountFailsWhenRelationIdentityProjectionCannotLoad(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 "id","identity" FROM "gas_basic" WHERE id IN ($1)`)). - WithArgs(uint64(7)). - WillReturnError(errors.New("relation lookup unavailable")) - - ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account", "", nil) - ListGasAccount(ctx) - - if strings.Contains(recorder.Body.String(), `"code":0`) { - t.Fatalf("relation projection failure was returned as success: %s", recorder.Body.String()) - } - assertMockExpectations(t, mock) -} - -func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - now := time.Now().UTC() - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track" WHERE identity = $1 ORDER BY "delivery_track"."id" LIMIT $2`)). - WithArgs("track-a", 1). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_task_id", "started_at", "completed_at"}). - AddRow(uint64(7), "track-a", nil, nil, "enabled", 1, uint64(8), now, nil)) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track_point" WHERE delivery_track_id = $1 ORDER BY occurred_at asc`)). - WithArgs(uint64(7)). - 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", nil, nil, "enabled", 1, uint64(7), "arrival", now, "120.123", "30.456")) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "delivery_task" WHERE id IN ($1)`)). - WithArgs(uint64(8)). - WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(8), "task-a")) - 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/track-a", "track-a", nil) - GetDeliveryTrack(ctx) - - assertResponseCode(t, recorder, 0) - if strings.Contains(recorder.Body.String(), "120.123") || strings.Contains(recorder.Body.String(), "30.456") { - t.Fatalf("unauthorized response exposed precise coordinates: %s", recorder.Body.String()) - } - 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 TestReplacePlatformRoleMenusAllowsAnEmptySetToClearAssignmentsTransactionally(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectBegin() - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE identity = $1 ORDER BY "platform_role"."id" LIMIT $2`)). - WithArgs("role-a", 1). - WillReturnRows(platformRoleRows("role-a")) - mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM "platform_role_menu_relation" WHERE platform_role_id = $1`)). - WithArgs(uint64(1)). - WillReturnResult(sqlmock.NewResult(0, 2)) - mock.ExpectCommit() - - ctx, recorder := updateContext(http.MethodPut, "/roles/role-a/menus", "role-a", []byte(`{"menu_identities":[]}`)) - ctx.Set("Auth", &types.JwtClaims{Role: "root"}) - ReplacePlatformRoleMenus(ctx) - - assertResponseCode(t, recorder, 0) - assertMockExpectations(t, mock) -} - -func TestReplacePlatformRoleMenusRejectsSystemRoleBeforeChangingRelations(t *testing.T) { - _, mock := setupPlatformRoleDatabase(t) - mock.ExpectBegin() - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE identity = $1 ORDER BY "platform_role"."id" LIMIT $2`)). - WithArgs("root-role", 1). - WillReturnRows(platformSystemRoleRows("root-role")) - mock.ExpectRollback() - - ctx, recorder := updateContext(http.MethodPut, "/roles/root-role/menus", "root-role", []byte(`{"menu_identities":[]}`)) - ctx.Set("Auth", &types.JwtClaims{Role: "root"}) - ReplacePlatformRoleMenus(ctx) - - assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument))) - assertMockExpectations(t, mock) -} - -type responseBody struct { - Code int32 `json:"code"` - Message string `json:"message"` - Details json.RawMessage `json:"details"` -} - -func setupPlatformRoleDatabase(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) { - t.Helper() - previous := impl.DBService - sqlDatabase, mock, err := sqlmock.New() - if err != nil { - t.Fatal(err) - } - database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - impl.DBService = database - t.Cleanup(func() { - impl.DBService = previous - _ = sqlDatabase.Close() - }) - return database, mock -} - -func platformRoleRows(identity string) *sqlmock.Rows { - return sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "role_code", "name", "data_scope", "is_system"}). - AddRow(uint64(1), identity, nil, nil, "enabled", 1, identity, identity, "global", false) -} - -func platformSystemRoleRows(identity string) *sqlmock.Rows { - return sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "role_code", "name", "data_scope", "is_system"}). - AddRow(uint64(1), identity, nil, nil, "enabled", 1, identity, identity, "global", true) -} - -func updateContext(method, target, identity string, body []byte) (*gin.Context, *httptest.ResponseRecorder) { - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Request = httptest.NewRequest(method, target, bytes.NewReader(body)) - ctx.Request.Header.Set("Content-Type", "application/json") - ctx.Params = gin.Params{{Key: "identity", Value: identity}} - return ctx, recorder -} - -func assertResponseCode(t *testing.T, recorder *httptest.ResponseRecorder, want int32) { - t.Helper() - var reply responseBody - if err := json.Unmarshal(recorder.Body.Bytes(), &reply); err != nil { - t.Fatal(err) - } - if reply.Code != want { - t.Fatalf("response code = %d, want %d: %s", reply.Code, want, recorder.Body.String()) - } -} - -func assertMockExpectations(t *testing.T, mock sqlmock.Sqlmock) { - t.Helper() - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } -} - -func assertContract(t *testing.T, contracts []ResourceContract, domain, name string, mode ResourceMode, pageKind string) { - t.Helper() - for _, contract := range contracts { - if contract.Domain == domain && contract.Name == name && contract.Mode == mode && contract.PageKind == pageKind { - return - } - } - t.Fatalf("missing resource contract %s/%s with mode %q and page kind %q", domain, name, mode, pageKind) -} diff --git a/backend/api/internal/logic/platform/staff.go b/backend/api/internal/logic/platform/staff_account.go similarity index 64% rename from backend/api/internal/logic/platform/staff.go rename to backend/api/internal/logic/platform/staff_account.go index b40b499..1149e09 100644 --- a/backend/api/internal/logic/platform/staff.go +++ b/backend/api/internal/logic/platform/staff_account.go @@ -4,15 +4,16 @@ import ( "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" ) // ListStaff 查询服务人员分页列表。 -func ListStaff(ctx *gin.Context) { listPage[models.StaffAccount](ctx) } +func ListStaff(ctx *gin.Context) { common.ListPage[models.StaffAccount](ctx) } // GetStaff 查询一个服务人员档案。 -func GetStaff(ctx *gin.Context) { getByIdentity[models.StaffAccount](ctx) } +func GetStaff(ctx *gin.Context) { common.GetByIdentity[models.StaffAccount](ctx) } // CreateStaff 创建服务人员档案。 func CreateStaff(ctx *gin.Context) { @@ -36,17 +37,17 @@ func CreateStaff(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + gasBasicID, err := common.ResolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) + deliveryBasicID, err := common.ResolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - staff := models.StaffAccount{Entity: newEntity("draft"), Username: request.Username, PasswordHash: hash, Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RoleCode: request.RoleCode, GasBasicID: gasBasicID, DeliveryBasicID: deliveryBasicID, WorkStatus: request.WorkStatus} + staff := models.StaffAccount{Entity: common.NewEntity("draft"), Username: request.Username, PasswordHash: hash, Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RoleCode: request.RoleCode, GasBasicID: gasBasicID, DeliveryBasicID: deliveryBasicID, WorkStatus: request.WorkStatus} if staff.WorkStatus == "" { staff.WorkStatus = "off_duty" } @@ -54,7 +55,7 @@ func CreateStaff(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, staff) + common.RespondCreatedResource(ctx, staff) } // UpdateStaff 更新服务人员档案。 @@ -72,15 +73,15 @@ func UpdateStaff(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + gasBasicID, err := common.ResolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) + deliveryBasicID, err := common.ResolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.StaffAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "role_code": request.RoleCode, "gas_basic_id": gasBasicID, "delivery_basic_id": deliveryBasicID, "work_status": request.WorkStatus}, []string{"name", "phone", "avatar", "role_code", "gas_basic_id", "delivery_basic_id", "work_status"}) + common.UpdateAllowedByIdentity(ctx, &models.StaffAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "role_code": request.RoleCode, "gas_basic_id": gasBasicID, "delivery_basic_id": deliveryBasicID, "work_status": request.WorkStatus}, []string{"name", "phone", "avatar", "role_code", "gas_basic_id", "delivery_basic_id", "work_status"}) } diff --git a/backend/api/internal/logic/platform/secondary_resource.go b/backend/api/internal/logic/platform/staff_credential_user_address_user_service_relation.go similarity index 51% rename from backend/api/internal/logic/platform/secondary_resource.go rename to backend/api/internal/logic/platform/staff_credential_user_address_user_service_relation.go index 534ca60..779706d 100644 --- a/backend/api/internal/logic/platform/secondary_resource.go +++ b/backend/api/internal/logic/platform/staff_credential_user_address_user_service_relation.go @@ -6,6 +6,7 @@ import ( "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" ) @@ -17,25 +18,25 @@ type staffCredentialRequest struct { ExpiredAt *time.Time `json:"expired_at"` } -func ListStaffCredential(ctx *gin.Context) { listPage[models.StaffCredential](ctx) } -func GetStaffCredential(ctx *gin.Context) { getByIdentity[models.StaffCredential](ctx) } +func ListStaffCredential(ctx *gin.Context) { common.ListPage[models.StaffCredential](ctx) } +func GetStaffCredential(ctx *gin.Context) { common.GetByIdentity[models.StaffCredential](ctx) } func CreateStaffCredential(ctx *gin.Context) { var request staffCredentialRequest if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - staffAccountID, err := resolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, true) + staffAccountID, err := common.ResolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, true) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - credential := models.StaffCredential{Entity: newEntity("enabled"), StaffAccountID: staffAccountID, CredentialType: request.CredentialType, CredentialNo: request.CredentialNo, ExpiredAt: request.ExpiredAt} + credential := models.StaffCredential{Entity: common.NewEntity("enabled"), StaffAccountID: staffAccountID, CredentialType: request.CredentialType, CredentialNo: request.CredentialNo, ExpiredAt: request.ExpiredAt} if err := impl.DBService.Create(&credential).Error; err != nil { infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, credential) + common.RespondCreatedResource(ctx, credential) } func UpdateStaffCredential(ctx *gin.Context) { var request staffCredentialRequest @@ -43,12 +44,12 @@ func UpdateStaffCredential(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - staffAccountID, err := resolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, true) + staffAccountID, err := common.ResolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, true) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.StaffCredential{}, gin.H{"staff_account_id": staffAccountID, "credential_type": request.CredentialType, "credential_no": request.CredentialNo, "expired_at": request.ExpiredAt}, []string{"staff_account_id", "credential_type", "credential_no", "expired_at"}) + common.UpdateAllowedByIdentity(ctx, &models.StaffCredential{}, gin.H{"staff_account_id": staffAccountID, "credential_type": request.CredentialType, "credential_no": request.CredentialNo, "expired_at": request.ExpiredAt}, []string{"staff_account_id", "credential_type", "credential_no", "expired_at"}) } type userAddressRequest struct { @@ -59,25 +60,25 @@ type userAddressRequest struct { IsDefault bool `json:"is_default"` } -func ListUserAddress(ctx *gin.Context) { listPage[models.UserAddress](ctx) } -func GetUserAddress(ctx *gin.Context) { getByIdentity[models.UserAddress](ctx) } +func ListUserAddress(ctx *gin.Context) { common.ListPage[models.UserAddress](ctx) } +func GetUserAddress(ctx *gin.Context) { common.GetByIdentity[models.UserAddress](ctx) } func CreateUserAddress(ctx *gin.Context) { var request userAddressRequest if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - userAccountID, err := resolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) + userAccountID, err := common.ResolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - address := models.UserAddress{Entity: newEntity("enabled"), UserAccountID: userAccountID, Address: request.Address, Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: request.IsDefault} + address := models.UserAddress{Entity: common.NewEntity("enabled"), UserAccountID: userAccountID, Address: request.Address, Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: request.IsDefault} if err := impl.DBService.Create(&address).Error; err != nil { infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, address) + common.RespondCreatedResource(ctx, address) } func UpdateUserAddress(ctx *gin.Context) { var request userAddressRequest @@ -85,12 +86,12 @@ func UpdateUserAddress(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - userAccountID, err := resolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) + userAccountID, err := common.ResolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.UserAddress{}, gin.H{"user_account_id": userAccountID, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude, "is_default": request.IsDefault}, []string{"user_account_id", "address", "longitude", "latitude", "is_default"}) + common.UpdateAllowedByIdentity(ctx, &models.UserAddress{}, gin.H{"user_account_id": userAccountID, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude, "is_default": request.IsDefault}, []string{"user_account_id", "address", "longitude", "latitude", "is_default"}) } type userServiceRelationRequest struct { @@ -100,40 +101,40 @@ type userServiceRelationRequest struct { StaffAccountIdentity string `json:"staff_account_identity"` } -func ListUserServiceRelation(ctx *gin.Context) { listPage[models.UserServiceRelation](ctx) } -func GetUserServiceRelation(ctx *gin.Context) { getByIdentity[models.UserServiceRelation](ctx) } +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) { var request userServiceRelationRequest if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - userAccountID, err := resolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) + userAccountID, err := common.ResolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + gasBasicID, err := common.ResolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) + deliveryBasicID, err := common.ResolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - staffAccountID, err := resolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, false) + staffAccountID, err := common.ResolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - relation := models.UserServiceRelation{Entity: newEntity("enabled"), UserAccountID: userAccountID, GasBasicID: gasBasicID, DeliveryBasicID: deliveryBasicID, StaffAccountID: staffAccountID} + relation := models.UserServiceRelation{Entity: common.NewEntity("enabled"), UserAccountID: userAccountID, GasBasicID: gasBasicID, DeliveryBasicID: deliveryBasicID, StaffAccountID: staffAccountID} if err := impl.DBService.Create(&relation).Error; err != nil { infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, relation) + common.RespondCreatedResource(ctx, relation) } func UpdateUserServiceRelation(ctx *gin.Context) { var request userServiceRelationRequest @@ -141,25 +142,25 @@ func UpdateUserServiceRelation(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - userAccountID, err := resolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) + userAccountID, err := common.ResolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + gasBasicID, err := common.ResolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) + deliveryBasicID, err := common.ResolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - staffAccountID, err := resolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, false) + staffAccountID, err := common.ResolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, false) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - 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"}) + 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"}) } diff --git a/backend/api/internal/logic/platform/test_helpers_test.go b/backend/api/internal/logic/platform/test_helpers_test.go new file mode 100644 index 0000000..4b43b10 --- /dev/null +++ b/backend/api/internal/logic/platform/test_helpers_test.go @@ -0,0 +1,36 @@ +package platform + +import ( + "testing" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func setupPlatformRoleDatabase(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) { + t.Helper() + previous := impl.DBService + sqlDatabase, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + impl.DBService = database + t.Cleanup(func() { + impl.DBService = previous + _ = sqlDatabase.Close() + }) + return database, mock +} + +func assertMockExpectations(t *testing.T, mock sqlmock.Sqlmock) { + t.Helper() + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/platform/user.go b/backend/api/internal/logic/platform/user_account.go similarity index 69% rename from backend/api/internal/logic/platform/user.go rename to backend/api/internal/logic/platform/user_account.go index a47090e..6927bac 100644 --- a/backend/api/internal/logic/platform/user.go +++ b/backend/api/internal/logic/platform/user_account.go @@ -4,15 +4,16 @@ import ( "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" ) // ListUser 查询业主客户分页列表。 -func ListUser(ctx *gin.Context) { listPage[models.UserAccount](ctx) } +func ListUser(ctx *gin.Context) { common.ListPage[models.UserAccount](ctx) } // GetUser 查询一个业主客户档案。 -func GetUser(ctx *gin.Context) { getByIdentity[models.UserAccount](ctx) } +func GetUser(ctx *gin.Context) { common.GetByIdentity[models.UserAccount](ctx) } // CreateUser 创建业主客户档案。 func CreateUser(ctx *gin.Context) { @@ -33,12 +34,12 @@ func CreateUser(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - user := models.UserAccount{Entity: newEntity("enabled"), Username: request.Username, PasswordHash: hash, Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RealName: request.RealName} + user := models.UserAccount{Entity: common.NewEntity("enabled"), Username: request.Username, PasswordHash: hash, Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RealName: request.RealName} if err := impl.DBService.Create(&user).Error; err != nil { infra.Response.Error(ctx, err) return } - respondCreatedResource(ctx, user) + common.RespondCreatedResource(ctx, user) } // UpdateUser 更新业主客户档案。 @@ -53,5 +54,5 @@ func UpdateUser(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.UserAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "real_name": request.RealName}, []string{"name", "phone", "avatar", "real_name"}) + common.UpdateAllowedByIdentity(ctx, &models.UserAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "real_name": request.RealName}, []string{"name", "phone", "avatar", "real_name"}) } diff --git a/backend/api/internal/logic/platform/wallet.go b/backend/api/internal/logic/platform/wallet_workflow.go similarity index 90% rename from backend/api/internal/logic/platform/wallet.go rename to backend/api/internal/logic/platform/wallet_workflow.go index 222a1f5..9ddf09d 100644 --- a/backend/api/internal/logic/platform/wallet.go +++ b/backend/api/internal/logic/platform/wallet_workflow.go @@ -12,6 +12,7 @@ import ( "git.apinb.com/bsm-sdk/core/middleware" "git.apinb.com/heqiapp/platforms/backend/api/internal/config" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "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" @@ -39,11 +40,11 @@ func ListWalletApplyCash(ctx *gin.Context) { listWalletPage[models.WalletApplyCa func GetWalletApplyCash(ctx *gin.Context) { getWalletByIdentity[models.WalletApplyCash](ctx) } func listWalletPage[T any](ctx *gin.Context) { - page, size := pageSize(ctx) + page, size := common.PageSize(ctx) var list []T var total int64 model := new(T) - query := applyKeywordFilter(ctx, impl.DBService.Model(model), model) + query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(model), model) if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return @@ -52,28 +53,28 @@ func listWalletPage[T any](ctx *gin.Context) { infra.Response.Error(ctx, err) return } - response, err := publicResourceResponse(list) + response, err := common.PublicResourceResponse(list) if err != nil { infra.Response.Error(ctx, err) return } protectWalletResponse(response, false) - infra.Response.Success(ctx, gin.H{"total": total, "list": protectPreciseLocation(ctx, model, response)}) + infra.Response.Success(ctx, gin.H{"total": total, "list": common.ProtectPreciseLocation(ctx, model, response)}) } func getWalletByIdentity[T any](ctx *gin.Context) { var data T if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&data).Error; err != nil { - respondRecordError(ctx, err) + common.RespondRecordError(ctx, err) return } - response, err := publicResourceResponse(data) + response, err := common.PublicResourceResponse(data) if err != nil { infra.Response.Error(ctx, err) return } protectWalletResponse(response, true) - infra.Response.Success(ctx, protectPreciseLocation(ctx, new(T), response)) + infra.Response.Success(ctx, common.ProtectPreciseLocation(ctx, new(T), response)) } func protectWalletResponse(value any, includeDetails bool) { @@ -89,7 +90,7 @@ func protectWalletResponse(value any, includeDetails bool) { delete(data, "card_no_last4") } if owner, ok := data["card_owner"].(string); ok && owner != "" { - data["card_owner"] = maskPersonalNameValue(owner) + data["card_owner"] = common.MaskPersonalNameValue(owner) } if !includeDetails { delete(data, "args") @@ -120,7 +121,7 @@ func GetOrCreateOwnerWallet(ctx *gin.Context) { ownerIdentity := strings.TrimSpace(ctx.Param("owner_identity")) ownerID, err := resolveWalletOwner(ownerType, ownerIdentity) if err != nil { - respondRecordError(ctx, err) + common.RespondRecordError(ctx, err) return } var wallet models.WalletBasic @@ -145,12 +146,12 @@ func GetOrCreateOwnerWallet(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - response, err := publicResourceResponse(wallet) + response, err := common.PublicResourceResponse(wallet) if err != nil { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, protectPreciseLocation(ctx, &models.WalletBasic{}, response)) + infra.Response.Success(ctx, common.ProtectPreciseLocation(ctx, &models.WalletBasic{}, response)) } func resolveWalletOwner(ownerType, ownerIdentity string) (uint64, error) { @@ -164,7 +165,7 @@ func resolveWalletOwner(ownerType, ownerIdentity string) (uint64, error) { if model == nil || ownerIdentity == "" { return 0, errors.New("invalid wallet owner") } - return resolveIdentityID(model, ownerIdentity, true) + return common.ResolveIdentityID(model, ownerIdentity, true) } func UpdateWalletBasicStatus(ctx *gin.Context) { @@ -175,7 +176,7 @@ func UpdateWalletBasicStatus(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.WalletBasic{}, gin.H{"status": request.Status}, []string{"status"}) + common.UpdateAllowedByIdentity(ctx, &models.WalletBasic{}, gin.H{"status": request.Status}, []string{"status"}) } func RechargeWalletBasic(ctx *gin.Context) { @@ -238,10 +239,10 @@ func RechargeWalletBasic(ctx *gin.Context) { return tx.Create(&record).Error }) if err != nil { - respondRecordError(ctx, err) + common.RespondRecordError(ctx, err) return } - response, err := publicResourceResponse(record) + response, err := common.PublicResourceResponse(record) if err != nil { infra.Response.Error(ctx, err) return @@ -299,7 +300,7 @@ func reviewWalletApplyCash(ctx *gin.Context, targetStatus string) { }).Error }) if err != nil { - respondRecordError(ctx, err) + common.RespondRecordError(ctx, err) return } infra.Response.Success(ctx, gin.H{"updated": true, "status": targetStatus}) diff --git a/backend/api/internal/logic/platform/wallet_test.go b/backend/api/internal/logic/platform/wallet_workflow_test.go similarity index 100% rename from backend/api/internal/logic/platform/wallet_test.go rename to backend/api/internal/logic/platform/wallet_workflow_test.go diff --git a/backend/api/internal/models/delivery_task.go b/backend/api/internal/models/delivery_task.go deleted file mode 100644 index 9dd4d14..0000000 --- a/backend/api/internal/models/delivery_task.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -import "git.apinb.com/bsm-sdk/core/database" - -// DeliveryTask 对应 delivery_task,保存配送履约任务。 -type DeliveryTask struct { - Entity // 公共实体字段 - EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段 - StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"` // staff_account_id 业务字段 - DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;index" json:"delivery_point_id"` // delivery_point_id 业务字段 -} - -func init() { database.AppendMigrate(&DeliveryTask{}) } -func (table *DeliveryTask) TableName() string { return "delivery_task" } diff --git a/backend/api/internal/models/delivery_track.go b/backend/api/internal/models/delivery_track.go deleted file mode 100644 index 6af10d1..0000000 --- a/backend/api/internal/models/delivery_track.go +++ /dev/null @@ -1,17 +0,0 @@ -package models - -import ( - "git.apinb.com/bsm-sdk/core/database" - "time" -) - -// DeliveryTrack 对应 delivery_track,保存配送轨迹摘要。 -type DeliveryTrack struct { - Entity // 公共实体字段 - DeliveryTaskID uint64 `gorm:"column:delivery_task_id;not null;index" json:"delivery_task_id"` // delivery_task_id 业务字段 - StartedAt *time.Time `gorm:"column:started_at;type:timestamptz" json:"started_at"` // started_at 业务字段 - CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // completed_at 业务字段 -} - -func init() { database.AppendMigrate(&DeliveryTrack{}) } -func (table *DeliveryTrack) TableName() string { return "delivery_track" } diff --git a/backend/api/internal/models/delivery_track_point.go b/backend/api/internal/models/delivery_track_point.go deleted file mode 100644 index 4e8f446..0000000 --- a/backend/api/internal/models/delivery_track_point.go +++ /dev/null @@ -1,19 +0,0 @@ -package models - -import ( - "git.apinb.com/bsm-sdk/core/database" - "time" -) - -// DeliveryTrackPoint 对应 delivery_track_point,保存配送节点和位置。 -type DeliveryTrackPoint struct { - Entity // 公共实体字段 - DeliveryTrackID uint64 `gorm:"column:delivery_track_id;not null;index" json:"delivery_track_id"` // delivery_track_id 业务字段 - PointType string `gorm:"column:point_type;type:varchar(32);not null" json:"point_type"` // point_type 业务字段 - OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null" json:"occurred_at"` // occurred_at 业务字段 - Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` // longitude 业务字段 - Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` // latitude 业务字段 -} - -func init() { database.AppendMigrate(&DeliveryTrackPoint{}) } -func (table *DeliveryTrackPoint) TableName() string { return "delivery_track_point" } diff --git a/backend/api/internal/models/gasorder_assign.go b/backend/api/internal/models/gasorder_assign.go new file mode 100644 index 0000000..8f38817 --- /dev/null +++ b/backend/api/internal/models/gasorder_assign.go @@ -0,0 +1,23 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// GasorderAssign 对应 gasorder_assign,保存订单履约分配历史。 +type GasorderAssign struct { + Entity // 公共实体字段 + GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;index" json:"gasorder_basic_id"` // 订单自增主键 + GasBasicID uint64 `gorm:"column:gas_basic_id;not null;index" json:"gas_basic_id"` // 气站自增主键 + DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;index" json:"delivery_basic_id"` // 配送点自增主键 + StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"` // 配送人员自增主键 + AssignerIdentity string `gorm:"column:assigner_identity;type:varchar(36);not null;default:''" json:"assigner_identity"` // 分配人标识 + AssignerName string `gorm:"column:assigner_name;type:varchar(64);not null;default:''" json:"assigner_name"` // 分配人姓名快照 + AssignedAt time.Time `gorm:"column:assigned_at;type:timestamptz;not null;index" json:"assigned_at"` // 分配时间 + Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"` // 分配原因 +} + +func init() { database.AppendMigrate(&GasorderAssign{}) } +func (table *GasorderAssign) TableName() string { return "gasorder_assign" } diff --git a/backend/api/internal/models/gasorder_basic.go b/backend/api/internal/models/gasorder_basic.go new file mode 100644 index 0000000..cbb9b8a --- /dev/null +++ b/backend/api/internal/models/gasorder_basic.go @@ -0,0 +1,34 @@ +package models + +import "git.apinb.com/bsm-sdk/core/database" + +// GasorderBasic 对应 gasorder_basic,保存气体配送订单当前快照。 +type GasorderBasic struct { + Entity // 公共实体字段,Status 保存订单当前状态 + OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"` // 订单编号 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex" json:"request_no"` // 创建幂等号 + GasorderContractID uint64 `gorm:"column:gasorder_contract_id;not null;index" json:"gasorder_contract_id"` // 合同自增主键 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // 服务用户自增主键 + CreatorType string `gorm:"column:creator_type;type:varchar(32);not null" json:"creator_type"` // 业务创建方类型 + CreatorID uint64 `gorm:"column:creator_id;not null;default:0;index" json:"creator_id"` // 业务创建方自增主键 + CreatorIdentity string `gorm:"column:creator_identity;type:varchar(36);not null;index" json:"creator_identity"` // 业务创建方标识 + GasBasicID uint64 `gorm:"column:gas_basic_id;not null;index" json:"gas_basic_id"` // 履约气站自增主键 + DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 当前配送点自增主键 + StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"` // 当前配送人员自增主键 + Address string `gorm:"column:address;type:varchar(255);not null" json:"address"` // 配送地址快照 + Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` // 配送经度快照 + Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` // 配送纬度快照 + ContactName string `gorm:"column:contact_name;type:varchar(64);not null" json:"contact_name"` // 联系人快照 + ContactPhone string `gorm:"column:contact_phone;type:varchar(32);not null" json:"contact_phone"` // 联系电话快照 + ProductAmount int64 `gorm:"column:product_amount;not null;check:product_amount >= 0" json:"product_amount"` // 商品金额,单位分 + DeliveryFee int64 `gorm:"column:delivery_fee;not null;default:0;check:delivery_fee >= 0" json:"delivery_fee"` // 配送费,单位分 + DiscountAmount int64 `gorm:"column:discount_amount;not null;default:0;check:discount_amount >= 0" json:"discount_amount"` // 优惠金额,单位分 + PayableAmount int64 `gorm:"column:payable_amount;not null;check:payable_amount > 0" json:"payable_amount"` // 应付金额,单位分 + PreviousStatus string `gorm:"column:previous_status;type:varchar(32);not null;default:''" json:"previous_status"` // 异常前状态 + OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:''" json:"operator_identity"` // 建单操作人标识 + OperatorName string `gorm:"column:operator_name;type:varchar(64);not null;default:''" json:"operator_name"` // 建单操作人姓名快照 + Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 订单备注 +} + +func init() { database.AppendMigrate(&GasorderBasic{}) } +func (table *GasorderBasic) TableName() string { return "gasorder_basic" } diff --git a/backend/api/internal/models/gasorder_confirm.go b/backend/api/internal/models/gasorder_confirm.go new file mode 100644 index 0000000..e734ac4 --- /dev/null +++ b/backend/api/internal/models/gasorder_confirm.go @@ -0,0 +1,22 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// GasorderConfirm 对应 gasorder_confirm,保存用户签收确认。 +type GasorderConfirm struct { + Entity // 公共实体字段 + GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;uniqueIndex" json:"gasorder_basic_id"` // 订单自增主键 + ConfirmType string `gorm:"column:confirm_type;type:varchar(32);not null" json:"confirm_type"` // 签收类型 + RecipientName string `gorm:"column:recipient_name;type:varchar(64);not null" json:"recipient_name"` // 签收人姓名快照 + RecipientPhone string `gorm:"column:recipient_phone;type:varchar(32);not null;default:''" json:"recipient_phone"` // 签收人手机号快照 + ProofURI string `gorm:"column:proof_uri;type:varchar(512);not null;default:''" json:"proof_uri"` // 签名或凭证地址 + ConfirmedAt time.Time `gorm:"column:confirmed_at;type:timestamptz;not null;index" json:"confirmed_at"` // 确认时间 + Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 签收备注 +} + +func init() { database.AppendMigrate(&GasorderConfirm{}) } +func (table *GasorderConfirm) TableName() string { return "gasorder_confirm" } diff --git a/backend/api/internal/models/gasorder_contract.go b/backend/api/internal/models/gasorder_contract.go new file mode 100644 index 0000000..25f3f29 --- /dev/null +++ b/backend/api/internal/models/gasorder_contract.go @@ -0,0 +1,26 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// GasorderContract 对应 gasorder_contract,保存用户与气站唯一供气合同。 +type GasorderContract struct { + Entity // 公共实体字段 + ContractNo string `gorm:"column:contract_no;type:varchar(64);not null;uniqueIndex" json:"contract_no"` // 合同编号 + UserAccountID uint64 `gorm:"column:user_account_id;not null;uniqueIndex:idx_gasorder_contract_party" json:"user_account_id"` // 签约用户自增主键 + GasBasicID uint64 `gorm:"column:gas_basic_id;not null;uniqueIndex:idx_gasorder_contract_party" json:"gas_basic_id"` // 签约气站自增主键 + DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 默认配送点自增主键 + Title string `gorm:"column:title;type:varchar(255);not null" json:"title"` // 合同标题 + Terms string `gorm:"column:terms;type:text;not null;default:''" json:"terms"` // 合同条款 + FileURI string `gorm:"column:file_uri;type:varchar(512);not null;default:''" json:"file_uri"` // 合同文件地址 + DefaultDeliveryFee int64 `gorm:"column:default_delivery_fee;not null;default:0;check:default_delivery_fee >= 0" json:"default_delivery_fee"` // 默认配送费,单位分 + SignedAt time.Time `gorm:"column:signed_at;type:timestamptz;not null" json:"signed_at"` // 签订时间 + EffectiveAt time.Time `gorm:"column:effective_at;type:timestamptz;not null" json:"effective_at"` // 生效时间 + ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"` // 到期时间 +} + +func init() { database.AppendMigrate(&GasorderContract{}) } +func (table *GasorderContract) TableName() string { return "gasorder_contract" } diff --git a/backend/api/internal/models/gasorder_contract_product.go b/backend/api/internal/models/gasorder_contract_product.go new file mode 100644 index 0000000..56fda30 --- /dev/null +++ b/backend/api/internal/models/gasorder_contract_product.go @@ -0,0 +1,23 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// GasorderContractProduct 对应 gasorder_contract_product,保存合同气瓶绑定历史。 +type GasorderContractProduct struct { + Entity // 公共实体字段 + GasorderContractID uint64 `gorm:"column:gasorder_contract_id;not null;index" json:"gasorder_contract_id"` // 合同自增主键 + ProductInfoID uint64 `gorm:"column:product_info_id;not null;index;uniqueIndex:idx_active_contract_product,where:unbound_at IS NULL" json:"product_info_id"` // 实体气瓶自增主键 + ProductCode string `gorm:"column:product_code;type:varchar(64);not null" json:"product_code"` // 产品标识快照 + ProductTypeName string `gorm:"column:product_type_name;type:varchar(128);not null" json:"product_type_name"` // 产品类型名称快照 + ProductParams string `gorm:"column:product_params;type:text;not null;default:'{}'" json:"product_params"` // 产品规格参数快照 + UnitPrice int64 `gorm:"column:unit_price;not null;check:unit_price >= 0" json:"unit_price"` // 约定充装单价,单位分 + BoundAt time.Time `gorm:"column:bound_at;type:timestamptz;not null" json:"bound_at"` // 绑定时间 + UnboundAt *time.Time `gorm:"column:unbound_at;type:timestamptz;index" json:"unbound_at"` // 解绑时间 +} + +func init() { database.AppendMigrate(&GasorderContractProduct{}) } +func (table *GasorderContractProduct) TableName() string { return "gasorder_contract_product" } diff --git a/backend/api/internal/models/gasorder_contract_revision.go b/backend/api/internal/models/gasorder_contract_revision.go new file mode 100644 index 0000000..c47a740 --- /dev/null +++ b/backend/api/internal/models/gasorder_contract_revision.go @@ -0,0 +1,24 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// GasorderContractRevision 对应 gasorder_contract_revision,保存唯一合同的不可变变更快照。 +type GasorderContractRevision struct { + Entity // 公共实体字段 + GasorderContractID uint64 `gorm:"column:gasorder_contract_id;not null;index" json:"gasorder_contract_id"` // 合同自增主键 + Action string `gorm:"column:action;type:varchar(32);not null;index" json:"action"` // 合同变更动作 + ContractStatus string `gorm:"column:contract_status;type:varchar(32);not null" json:"contract_status"` // 合同状态快照 + EffectiveAt time.Time `gorm:"column:effective_at;type:timestamptz;not null" json:"effective_at"` // 生效时间快照 + ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"` // 到期时间快照 + OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:''" json:"operator_identity"` // 操作人标识 + OperatorName string `gorm:"column:operator_name;type:varchar(64);not null;default:''" json:"operator_name"` // 操作人姓名快照 + OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null;index" json:"occurred_at"` // 发生时间 + Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"` // 变更原因 +} + +func init() { database.AppendMigrate(&GasorderContractRevision{}) } +func (table *GasorderContractRevision) TableName() string { return "gasorder_contract_revision" } diff --git a/backend/api/internal/models/gasorder_item.go b/backend/api/internal/models/gasorder_item.go new file mode 100644 index 0000000..3e9cc15 --- /dev/null +++ b/backend/api/internal/models/gasorder_item.go @@ -0,0 +1,18 @@ +package models + +import "git.apinb.com/bsm-sdk/core/database" + +// GasorderItem 对应 gasorder_item,保存订单气瓶与规格价格快照。 +type GasorderItem struct { + Entity // 公共实体字段 + GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;index" json:"gasorder_basic_id"` // 订单自增主键 + GasorderContractProductID uint64 `gorm:"column:gasorder_contract_product_id;not null;index" json:"gasorder_contract_product_id"` // 合同气瓶绑定自增主键 + ProductInfoID uint64 `gorm:"column:product_info_id;not null;index" json:"product_info_id"` // 实体气瓶自增主键 + ProductCode string `gorm:"column:product_code;type:varchar(64);not null" json:"product_code"` // 产品标识快照 + ProductTypeName string `gorm:"column:product_type_name;type:varchar(128);not null" json:"product_type_name"` // 产品类型名称快照 + ProductParams string `gorm:"column:product_params;type:text;not null;default:'{}'" json:"product_params"` // 产品规格参数快照 + UnitPrice int64 `gorm:"column:unit_price;not null;check:unit_price >= 0" json:"unit_price"` // 成交单价,单位分 +} + +func init() { database.AppendMigrate(&GasorderItem{}) } +func (table *GasorderItem) TableName() string { return "gasorder_item" } diff --git a/backend/api/internal/models/gasorder_payment.go b/backend/api/internal/models/gasorder_payment.go new file mode 100644 index 0000000..d435f71 --- /dev/null +++ b/backend/api/internal/models/gasorder_payment.go @@ -0,0 +1,15 @@ +package models + +import "git.apinb.com/bsm-sdk/core/database" + +// GasorderPayment 对应 gasorder_payment,保存订单支付尝试与统一钱包支付关联。 +type GasorderPayment struct { + Entity // 公共实体字段,Status 保存支付尝试状态 + GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;index;uniqueIndex:idx_gasorder_payment_attempt" json:"gasorder_basic_id"` // 订单自增主键 + WalletPaymentID uint64 `gorm:"column:wallet_payment_id;not null;uniqueIndex" json:"wallet_payment_id"` // 钱包支付记录自增主键 + AttemptNo int `gorm:"column:attempt_no;not null;uniqueIndex:idx_gasorder_payment_attempt" json:"attempt_no"` // 支付尝试序号 + Amount int64 `gorm:"column:amount;not null;check:amount > 0" json:"amount"` // 支付金额,单位分 +} + +func init() { database.AppendMigrate(&GasorderPayment{}) } +func (table *GasorderPayment) TableName() string { return "gasorder_payment" } diff --git a/backend/api/internal/models/gasorder_status.go b/backend/api/internal/models/gasorder_status.go new file mode 100644 index 0000000..00a085f --- /dev/null +++ b/backend/api/internal/models/gasorder_status.go @@ -0,0 +1,22 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// GasorderStatus 对应 gasorder_status,保存不可变订单状态历史。 +type GasorderStatus struct { + Entity // 公共实体字段 + GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;index" json:"gasorder_basic_id"` // 订单自增主键 + FromStatus string `gorm:"column:from_status;type:varchar(32);not null;default:''" json:"from_status"` // 原状态 + ToStatus string `gorm:"column:to_status;type:varchar(32);not null;index" json:"to_status"` // 新状态 + OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:''" json:"operator_identity"` // 操作人标识 + OperatorName string `gorm:"column:operator_name;type:varchar(64);not null;default:''" json:"operator_name"` // 操作人姓名快照 + OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null;index" json:"occurred_at"` // 发生时间 + Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"` // 变更原因 +} + +func init() { database.AppendMigrate(&GasorderStatus{}) } +func (table *GasorderStatus) TableName() string { return "gasorder_status" } diff --git a/backend/api/internal/models/gasorder_track.go b/backend/api/internal/models/gasorder_track.go new file mode 100644 index 0000000..46a8c54 --- /dev/null +++ b/backend/api/internal/models/gasorder_track.go @@ -0,0 +1,20 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// GasorderTrack 对应 gasorder_track,保存一次配送尝试的轨迹摘要。 +type GasorderTrack struct { + Entity // 公共实体字段 + GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;index;uniqueIndex:idx_gasorder_track_attempt" json:"gasorder_basic_id"` // 订单自增主键 + StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"` // 配送人员自增主键 + AttemptNo int `gorm:"column:attempt_no;not null;uniqueIndex:idx_gasorder_track_attempt" json:"attempt_no"` // 配送尝试序号 + StartedAt time.Time `gorm:"column:started_at;type:timestamptz;not null;index" json:"started_at"` // 开始时间 + CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 到达时间 +} + +func init() { database.AppendMigrate(&GasorderTrack{}) } +func (table *GasorderTrack) TableName() string { return "gasorder_track" } diff --git a/backend/api/internal/models/gasorder_track_point.go b/backend/api/internal/models/gasorder_track_point.go new file mode 100644 index 0000000..bac1391 --- /dev/null +++ b/backend/api/internal/models/gasorder_track_point.go @@ -0,0 +1,21 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// GasorderTrackPoint 对应 gasorder_track_point,保存不可变配送位置点。 +type GasorderTrackPoint struct { + Entity // 公共实体字段 + GasorderTrackID uint64 `gorm:"column:gasorder_track_id;not null;index" json:"gasorder_track_id"` // 轨迹自增主键 + Longitude string `gorm:"column:longitude;type:varchar(32);not null" json:"longitude"` // 经度 + Latitude string `gorm:"column:latitude;type:varchar(32);not null" json:"latitude"` // 纬度 + OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null;index" json:"occurred_at"` // 定位发生时间 + Source string `gorm:"column:source;type:varchar(32);not null;default:'gps'" json:"source"` // 定位来源 + Accuracy string `gorm:"column:accuracy;type:varchar(32);not null;default:''" json:"accuracy"` // 定位精度 +} + +func init() { database.AppendMigrate(&GasorderTrackPoint{}) } +func (table *GasorderTrackPoint) TableName() string { return "gasorder_track_point" } diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index d547ab8..b1b400d 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -4,6 +4,7 @@ import ( "fmt" "git.apinb.com/bsm-sdk/core/middleware" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" @@ -25,6 +26,7 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) { registerGasRoute(protected) registerDeliveryRoute(protected) + registerGasorderRoute(protected) registerStaffRoute(protected) registerUserRoute(protected) registerProductRoute(protected) @@ -36,26 +38,57 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) { } func registerGasRoute(group *gin.RouterGroup) { - registerWritableResource(group, "/gas/gas_basic", platform.ListGasBasic, platform.CreateGasBasic, platform.GetGasBasic, platform.UpdateGasBasic, &models.GasBasic{}) - registerWritableResource(group, "/gas/gas_account", platform.ListGasAccount, platform.CreateGasAccount, platform.GetGasAccount, platform.UpdateGasAccount, &models.GasAccount{}) + registerWritableResource(group, "/gas_basic", platform.ListGasBasic, platform.CreateGasBasic, platform.GetGasBasic, platform.UpdateGasBasic, &models.GasBasic{}) + registerWritableResource(group, "/gas_account", platform.ListGasAccount, platform.CreateGasAccount, platform.GetGasAccount, platform.UpdateGasAccount, &models.GasAccount{}) } func registerDeliveryRoute(group *gin.RouterGroup) { - registerWritableResource(group, "/delivery/delivery_basic", platform.ListDeliveryBasic, platform.CreateDeliveryBasic, platform.GetDeliveryBasic, platform.UpdateDeliveryBasic, &models.DeliveryBasic{}) - registerWritableResource(group, "/delivery/delivery_account", platform.ListDeliveryAccount, platform.CreateDeliveryAccount, platform.GetDeliveryAccount, platform.UpdateDeliveryAccount, &models.DeliveryAccount{}) - registerRestrictedWritableResource(group, "/delivery/delivery_task", &models.DeliveryTask{}, nil, - requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{}), optionalRelation("staff_account_identity", "staff_account_id", &models.StaffAccount{}), requiredRelation("delivery_basic_identity", "delivery_point_id", &models.DeliveryBasic{})) - trackRelations := []platform.ResourceRelation{requiredRelation("delivery_task_identity", "delivery_task_id", &models.DeliveryTask{})} - list, create, _, update := platform.ResourceHandlers(&models.DeliveryTrack{}, []string{"started_at", "completed_at"}, []string{"started_at", "completed_at"}, trackRelations...) - registerWritableResource(group, "/delivery/delivery_track", list, create, platform.GetDeliveryTrack, update, &models.DeliveryTrack{}) - registerReadOnlyResource(group, "/delivery/delivery_track_point", &models.DeliveryTrackPoint{}) + registerWritableResource(group, "/delivery_basic", platform.ListDeliveryBasic, platform.CreateDeliveryBasic, platform.GetDeliveryBasic, platform.UpdateDeliveryBasic, &models.DeliveryBasic{}) + registerWritableResource(group, "/delivery_account", platform.ListDeliveryAccount, platform.CreateDeliveryAccount, platform.GetDeliveryAccount, platform.UpdateDeliveryAccount, &models.DeliveryAccount{}) +} + +func registerGasorderRoute(group *gin.RouterGroup) { + contract := group.Group("/gasorder_contract") + contract.GET("", platform.ListGasorderContract) + contract.POST("", platform.CreateGasorderContract) + contract.GET("/:identity", platform.GetGasorderContract) + contract.PUT("/:identity", platform.UpdateGasorderContract) + contract.POST("/:identity/activate", platform.ActivateGasorderContract) + contract.POST("/:identity/renew", platform.RenewGasorderContract) + contract.POST("/:identity/terminate", platform.TerminateGasorderContract) + + contractProduct := group.Group("/gasorder_contract_product") + contractProduct.GET("", platform.ListGasorderContractProduct) + contractProduct.POST("", platform.BindGasorderContractProduct) + contractProduct.GET("/:identity", platform.GetGasorderContractProduct) + contractProduct.POST("/:identity/unbind", platform.UnbindGasorderContractProduct) + registerReadOnlyHandlers(group, "/gasorder_contract_revision", platform.ListGasorderContractRevision, platform.GetGasorderContractRevision) + + order := group.Group("/gasorder_basic") + order.GET("", platform.ListGasorderBasic) + order.POST("", platform.CreateGasorderBasic) + order.GET("/:identity", platform.GetGasorderBasic) + order.POST("/:identity/assign", platform.AssignGasorderBasic) + order.POST("/:identity/filling", platform.GasorderStartFilling) + order.POST("/:identity/ready", platform.GasorderReady) + order.POST("/:identity/exception", platform.GasorderException) + order.POST("/:identity/recover", platform.GasorderRecover) + order.POST("/:identity/cancel", platform.GasorderCancel) + + registerReadOnlyHandlers(group, "/gasorder_item", platform.ListGasorderItem, platform.GetGasorderItem) + registerReadOnlyHandlers(group, "/gasorder_assign", platform.ListGasorderAssign, platform.GetGasorderAssign) + registerReadOnlyHandlers(group, "/gasorder_status", platform.ListGasorderStatus, platform.GetGasorderStatus) + registerReadOnlyHandlers(group, "/gasorder_track", platform.ListGasorderTrack, platform.GetGasorderTrack) + registerReadOnlyHandlers(group, "/gasorder_track_point", platform.ListGasorderTrackPoint, platform.GetGasorderTrackPoint) + registerReadOnlyHandlers(group, "/gasorder_confirm", platform.ListGasorderConfirm, platform.GetGasorderConfirm) + registerReadOnlyHandlers(group, "/gasorder_payment", platform.ListGasorderPayment, platform.GetGasorderPayment) } func registerProductRoute(group *gin.RouterGroup) { registerRestrictedNoDeleteResource(group, "/product_type", &models.ProductType{}, []string{"code", "name"}) registerRestrictedNoDeleteResource(group, "/product_warehouse", &models.ProductWarehouse{}, []string{"code", "name", "address", "manager", "phone", "is_enabled"}) - infoRelations := []platform.ResourceRelation{ + infoRelations := []common.ResourceRelation{ requiredRelation("product_type_identity", "product_type_id", &models.ProductType{}), optionalRelation("warehouse_identity", "warehouse_id", &models.ProductWarehouse{}), optionalRelation("gas_basic_identity", "gas_basic_id", &models.GasBasic{}), @@ -120,34 +153,34 @@ func registerWalletRoute(group *gin.RouterGroup) { } func registerCommerceRoute(group *gin.RouterGroup) { - categoryRelations := []platform.ResourceRelation{optionalRelation("parent_identity", "parent_id", &models.EcCategory{})} - _, categoryCreate, _, categoryUpdate := platform.ResourceHandlers(&models.EcCategory{}, []string{"name", "sort_no"}, []string{"name", "sort_no"}, categoryRelations...) - registerWritableResource(group, "/ec/ec_category", platform.ListEcCategory, categoryCreate, platform.GetEcCategory, categoryUpdate, &models.EcCategory{}) - registerRestrictedWritableResource(group, "/ec/ec_product", &models.EcProduct{}, []string{"product_code", "name", "price_amount", "stock_quantity"}, requiredRelation("ec_category_identity", "ec_category_id", &models.EcCategory{})) - registerRestrictedWritableResource(group, "/ec/ec_product_attribute", &models.EcProductAttribute{}, []string{"name", "value", "sort_no"}, requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) - registerRestrictedWritableResource(group, "/ec/ec_product_image", &models.EcProductImage{}, []string{"image_uri", "sort_no", "is_cover"}, requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) - registerRestrictedWritableResource(group, "/ec/ec_cart", &models.EcCart{}, []string{"quantity", "selected"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{}), requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) - orderRelations := []platform.ResourceRelation{requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{}), optionalRelation("gas_basic_identity", "gas_station_id", &models.GasBasic{}), optionalRelation("delivery_basic_identity", "delivery_point_id", &models.DeliveryBasic{})} - list, create, _, update := platform.ResourceHandlers(&models.EcOrder{}, []string{"order_no", "total_amount"}, []string{"total_amount"}, orderRelations...) - registerWritableResource(group, "/ec/ec_order", list, create, platform.GetEcOrder, update, &models.EcOrder{}) - registerRestrictedWritableResource(group, "/ec/ec_order_item", &models.EcOrderItem{}, []string{"product_snapshot", "quantity", "sale_amount"}, requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{}), requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) - registerRestrictedWritableResource(group, "/ec/ec_review", &models.EcReview{}, []string{"score", "content"}, requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{}), requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{}), requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{})) + categoryRelations := []common.ResourceRelation{optionalRelation("parent_identity", "parent_id", &models.EcCategory{})} + _, categoryCreate, _, categoryUpdate := common.ResourceHandlers(&models.EcCategory{}, []string{"name", "sort_no"}, []string{"name", "sort_no"}, categoryRelations...) + registerWritableResource(group, "/ec_category", platform.ListEcCategory, categoryCreate, platform.GetEcCategory, categoryUpdate, &models.EcCategory{}) + registerRestrictedWritableResource(group, "/ec_product", &models.EcProduct{}, []string{"product_code", "name", "price_amount", "stock_quantity"}, requiredRelation("ec_category_identity", "ec_category_id", &models.EcCategory{})) + registerRestrictedWritableResource(group, "/ec_product_attribute", &models.EcProductAttribute{}, []string{"name", "value", "sort_no"}, requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) + registerRestrictedWritableResource(group, "/ec_product_image", &models.EcProductImage{}, []string{"image_uri", "sort_no", "is_cover"}, requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) + registerRestrictedWritableResource(group, "/ec_cart", &models.EcCart{}, []string{"quantity", "selected"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{}), requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) + orderRelations := []common.ResourceRelation{requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{}), optionalRelation("gas_basic_identity", "gas_station_id", &models.GasBasic{}), optionalRelation("delivery_basic_identity", "delivery_point_id", &models.DeliveryBasic{})} + list, create, _, update := common.ResourceHandlers(&models.EcOrder{}, []string{"order_no", "total_amount"}, []string{"total_amount"}, orderRelations...) + registerWritableResource(group, "/ec_order", list, create, platform.GetEcOrder, update, &models.EcOrder{}) + registerRestrictedWritableResource(group, "/ec_order_item", &models.EcOrderItem{}, []string{"product_snapshot", "quantity", "sale_amount"}, requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{}), requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) + registerRestrictedWritableResource(group, "/ec_review", &models.EcReview{}, []string{"score", "content"}, requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{}), requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{}), requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{})) } func registerStaffRoute(group *gin.RouterGroup) { - registerWritableResource(group, "/staff/account", platform.ListStaff, platform.CreateStaff, platform.GetStaff, platform.UpdateStaff, &models.StaffAccount{}) - registerWritableResource(group, "/staff/credential", platform.ListStaffCredential, platform.CreateStaffCredential, platform.GetStaffCredential, platform.UpdateStaffCredential, &models.StaffCredential{}) + registerWritableResource(group, "/staff_account", platform.ListStaff, platform.CreateStaff, platform.GetStaff, platform.UpdateStaff, &models.StaffAccount{}) + registerWritableResource(group, "/staff_credential", platform.ListStaffCredential, platform.CreateStaffCredential, platform.GetStaffCredential, platform.UpdateStaffCredential, &models.StaffCredential{}) } func registerUserRoute(group *gin.RouterGroup) { - registerWritableResource(group, "/user/account", platform.ListUser, platform.CreateUser, platform.GetUser, platform.UpdateUser, &models.UserAccount{}) - registerWritableResource(group, "/user/address", platform.ListUserAddress, platform.CreateUserAddress, platform.GetUserAddress, platform.UpdateUserAddress, &models.UserAddress{}) - registerWritableResource(group, "/user/service_relation", platform.ListUserServiceRelation, platform.CreateUserServiceRelation, platform.GetUserServiceRelation, platform.UpdateUserServiceRelation, &models.UserServiceRelation{}) + registerWritableResource(group, "/user_account", platform.ListUser, platform.CreateUser, platform.GetUser, platform.UpdateUser, &models.UserAccount{}) + registerWritableResource(group, "/user_address", platform.ListUserAddress, platform.CreateUserAddress, platform.GetUserAddress, platform.UpdateUserAddress, &models.UserAddress{}) + registerWritableResource(group, "/user_service_relation", platform.ListUserServiceRelation, platform.CreateUserServiceRelation, platform.GetUserServiceRelation, platform.UpdateUserServiceRelation, &models.UserServiceRelation{}) } func registerPlatformRoute(group *gin.RouterGroup) { - registerWritableResource(group, "/platform/platfrom_account", platform.ListPlatfromAccount, platform.CreatePlatfromAccount, platform.GetPlatfromAccount, platform.UpdatePlatfromAccount, &models.PlatfromAccount{}) - role := group.Group("/platform/platform_role") + registerWritableResource(group, "/platfrom_account", platform.ListPlatfromAccount, platform.CreatePlatfromAccount, platform.GetPlatfromAccount, platform.UpdatePlatfromAccount, &models.PlatfromAccount{}) + role := group.Group("/platform_role") role.GET("", platform.ListPlatformRole) role.POST("", platform.CreatePlatformRole) role.GET("/:identity", platform.GetPlatformRole) @@ -157,7 +190,7 @@ func registerPlatformRoute(group *gin.RouterGroup) { role.GET("/:identity/menu", platform.ListPlatformRoleMenuIdentities) role.PUT("/:identity/menu", platform.ReplacePlatformRoleMenus) role.PUT("/:identity/menus", platform.ReplacePlatformRoleMenus) - menu := group.Group("/platform/platform_menu") + menu := group.Group("/platform_menu") menu.GET("", platform.ListPlatformMenu) menu.POST("", platform.CreatePlatformMenu) menu.GET("/:identity", platform.GetPlatformMenu) @@ -167,16 +200,16 @@ func registerPlatformRoute(group *gin.RouterGroup) { } func registerFinanceRoute(group *gin.RouterGroup) { - registerRestrictedWritableResource(group, "/finance/fin_payment", &models.FinPayment{}, []string{"channel", "amount", "paid_at"}, requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{})) + registerRestrictedWritableResource(group, "/fin_payment", &models.FinPayment{}, []string{"channel", "amount", "paid_at"}, requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{})) settlementList, settlementCreate, settlementGet, settlementUpdate := platform.FinSettlementHandlers() - registerWritableResource(group, "/finance/fin_settlement", settlementList, settlementCreate, settlementGet, settlementUpdate, &models.FinSettlement{}) - registerRestrictedWritableResource(group, "/finance/fin_reconciliation", &models.FinReconciliation{}, []string{"channel", "bill_date", "difference_amount"}) + registerWritableResource(group, "/fin_settlement", settlementList, settlementCreate, settlementGet, settlementUpdate, &models.FinSettlement{}) + registerRestrictedWritableResource(group, "/fin_reconciliation", &models.FinReconciliation{}, []string{"channel", "bill_date", "difference_amount"}) } func registerContentRoute(group *gin.RouterGroup) { - registerRestrictedWritableResource(group, "/content/cms_content", &models.CmsContent{}, []string{"content_type", "title", "body", "version_no", "publish_status"}) - registerRestrictedWritableResource(group, "/customer_service/cs_ticket", &models.CsTicket{}, []string{"ticket_no", "category", "priority"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{})) + registerRestrictedWritableResource(group, "/cms_content", &models.CmsContent{}, []string{"content_type", "title", "body", "version_no", "publish_status"}) + registerRestrictedWritableResource(group, "/cs_ticket", &models.CsTicket{}, []string{"ticket_no", "category", "priority"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{})) } func registerWritableResource(group *gin.RouterGroup, path string, list, create, get, update gin.HandlerFunc, model any) { @@ -185,12 +218,12 @@ func registerWritableResource(group *gin.RouterGroup, path string, list, create, resource.POST("", create) resource.GET("/:identity", get) resource.PUT("/:identity", update) - resource.PATCH("/:identity/status", func(ctx *gin.Context) { platform.UpdateRecordStatus(ctx, model) }) - resource.DELETE("/:identity", func(ctx *gin.Context) { platform.ArchiveRecord(ctx, model) }) + resource.PATCH("/:identity/status", func(ctx *gin.Context) { common.UpdateRecordStatus(ctx, model) }) + resource.DELETE("/:identity", func(ctx *gin.Context) { common.ArchiveRecord(ctx, model) }) } -func registerRestrictedWritableResource(group *gin.RouterGroup, path string, model any, fields []string, relations ...platform.ResourceRelation) { - list, create, get, update := platform.ResourceHandlers(model, fields, fields, relations...) +func registerRestrictedWritableResource(group *gin.RouterGroup, path string, model any, fields []string, relations ...common.ResourceRelation) { + list, create, get, update := common.ResourceHandlers(model, fields, fields, relations...) registerWritableResource(group, path, list, create, get, update, model) } @@ -200,25 +233,31 @@ func registerNoDeleteResource(group *gin.RouterGroup, path string, list, create, resource.POST("", create) resource.GET("/:identity", get) resource.PUT("/:identity", update) - resource.PATCH("/:identity/status", func(ctx *gin.Context) { platform.UpdateRecordStatus(ctx, model) }) + resource.PATCH("/:identity/status", func(ctx *gin.Context) { common.UpdateRecordStatus(ctx, model) }) } -func registerRestrictedNoDeleteResource(group *gin.RouterGroup, path string, model any, fields []string, relations ...platform.ResourceRelation) { - list, create, get, update := platform.ResourceHandlers(model, fields, fields, relations...) +func registerRestrictedNoDeleteResource(group *gin.RouterGroup, path string, model any, fields []string, relations ...common.ResourceRelation) { + list, create, get, update := common.ResourceHandlers(model, fields, fields, relations...) registerNoDeleteResource(group, path, list, create, get, update, model) } func registerReadOnlyResource(group *gin.RouterGroup, path string, model any) { - list, _, get, _ := platform.ResourceHandlers(model, nil, nil) + list, _, get, _ := common.ResourceHandlers(model, nil, nil) resource := group.Group(path) resource.GET("", list) resource.GET("/:identity", get) } -func requiredRelation(input, column string, model any) platform.ResourceRelation { - return platform.ResourceRelation{Input: input, Column: column, Model: model, Required: true} +func registerReadOnlyHandlers(group *gin.RouterGroup, path string, list, get gin.HandlerFunc) { + resource := group.Group(path) + resource.GET("", list) + resource.GET("/:identity", get) } -func optionalRelation(input, column string, model any) platform.ResourceRelation { - return platform.ResourceRelation{Input: input, Column: column, Model: model} +func requiredRelation(input, column string, model any) common.ResourceRelation { + return common.ResourceRelation{Input: input, Column: column, Model: model, Required: true} +} + +func optionalRelation(input, column string, model any) common.ResourceRelation { + return common.ResourceRelation{Input: input, Column: column, Model: model} } diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index 4317d33..48e4cc7 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -35,6 +35,11 @@ func TestEveryContractHasRegisteredRoute(t *testing.T) { assertRouteMethods(t, routes, path+"/:identity/status", http.MethodPatch) assertNoRouteMethods(t, routes, path, http.MethodDelete) assertNoRouteMethods(t, routes, path+"/:identity", http.MethodDelete) + case platform.Managed: + assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost) + assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut) + assertNoRouteMethods(t, routes, path, http.MethodDelete) + assertNoRouteMethods(t, routes, path+"/:identity", http.MethodPatch, http.MethodDelete) default: assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost) assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete) @@ -51,7 +56,7 @@ func TestPlatformGasRouteUsesGasBasic(t *testing.T) { if route.Path == "/heqi/platform/v1/gas/gas_station" { t.Fatal("gas station route must use gas_basic as its resource name") } - if route.Method == "GET" && route.Path == "/heqi/platform/v1/gas/gas_basic" { + if route.Method == "GET" && route.Path == "/heqi/platform/v1/gas_basic" { return } } @@ -71,26 +76,26 @@ func TestPlatformOrganizationAndAccountRoutesExposeResourceCRUD(t *testing.T) { } for _, resource := range []string{ - "/gas/gas_basic", - "/gas/gas_account", - "/delivery/delivery_basic", - "/delivery/delivery_account", - "/staff/account", - "/staff/credential", - "/user/account", - "/user/address", - "/user/service_relation", - "/platform/platfrom_account", - "/platform/platform_role", - "/platform/platform_menu", + "/gas_basic", + "/gas_account", + "/delivery_basic", + "/delivery_account", + "/staff_account", + "/staff_credential", + "/user_account", + "/user_address", + "/user_service_relation", + "/platfrom_account", + "/platform_role", + "/platform_menu", } { assertRouteMethods(t, routes, "/heqi/platform/v1"+resource, http.MethodGet, http.MethodPost) assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete) assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity/status", http.MethodPatch) } - assertRouteMethods(t, routes, "/heqi/platform/v1/platform/platform_role/:identity/menu", http.MethodGet, http.MethodPut) - assertRouteMethods(t, routes, "/heqi/platform/v1/platform/platform_role/:identity/menus", http.MethodPut) + assertRouteMethods(t, routes, "/heqi/platform/v1/platform_role/:identity/menu", http.MethodGet, http.MethodPut) + assertRouteMethods(t, routes, "/heqi/platform/v1/platform_role/:identity/menus", http.MethodPut) } func TestPlatformProductCommerceAndDeliveryRoutesFollowTheirContracts(t *testing.T) { @@ -106,20 +111,30 @@ func TestPlatformProductCommerceAndDeliveryRoutesFollowTheirContracts(t *testing } for _, resource := range []string{ - "/ec/ec_category", "/ec/ec_product", "/ec/ec_product_attribute", "/ec/ec_product_image", "/ec/ec_cart", "/ec/ec_order", "/ec/ec_order_item", "/ec/ec_review", - "/delivery/delivery_task", "/delivery/delivery_track", + "/ec_category", "/ec_product", "/ec_product_attribute", "/ec_product_image", "/ec_cart", "/ec_order", "/ec_order_item", "/ec_review", } { assertRouteMethods(t, routes, "/heqi/platform/v1"+resource, http.MethodGet, http.MethodPost) assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete) assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity/status", http.MethodPatch) } - trackPoint := "/heqi/platform/v1/delivery/delivery_track_point" - assertRouteMethods(t, routes, trackPoint, http.MethodGet) - assertRouteMethods(t, routes, trackPoint+"/:identity", http.MethodGet) - assertNoRouteMethods(t, routes, trackPoint, http.MethodPost) - assertNoRouteMethods(t, routes, trackPoint+"/:identity", http.MethodPut, http.MethodDelete) - assertNoRouteMethods(t, routes, trackPoint+"/:identity/status", http.MethodPatch) + for _, resource := range []string{"/gasorder_item", "/gasorder_assign", "/gasorder_status", "/gasorder_track", "/gasorder_track_point", "/gasorder_confirm", "/gasorder_payment", "/gasorder_contract_revision"} { + path := "/heqi/platform/v1" + resource + assertRouteMethods(t, routes, path, http.MethodGet) + assertRouteMethods(t, routes, path+"/:identity", http.MethodGet) + assertNoRouteMethods(t, routes, path, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete) + assertNoRouteMethods(t, routes, path+"/:identity", http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete) + } + assertRouteMethods(t, routes, "/heqi/platform/v1/gasorder_contract", http.MethodGet, http.MethodPost) + assertRouteMethods(t, routes, "/heqi/platform/v1/gasorder_contract/:identity", http.MethodGet, http.MethodPut) + assertRouteMethods(t, routes, "/heqi/platform/v1/gasorder_basic", http.MethodGet, http.MethodPost) + assertRouteMethods(t, routes, "/heqi/platform/v1/gasorder_basic/:identity", http.MethodGet) + for _, action := range []string{"assign", "filling", "ready", "exception", "recover", "cancel"} { + assertRouteMethods(t, routes, "/heqi/platform/v1/gasorder_basic/:identity/"+action, http.MethodPost) + } + for _, old := range []string{"/delivery/delivery_task", "/delivery/delivery_track", "/delivery/delivery_track_point"} { + assertNoRouteMethods(t, routes, "/heqi/platform/v1"+old, http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete) + } for _, resource := range []string{"/product_type", "/product_warehouse", "/product_info", "/product_repair"} { path := "/heqi/platform/v1" + resource @@ -157,8 +172,8 @@ func TestPlatformFinanceContentRoutesFollowTheirContracts(t *testing.T) { } for _, resource := range []string{ - "/finance/fin_payment", "/finance/fin_settlement", "/finance/fin_reconciliation", - "/content/cms_content", "/customer_service/cs_ticket", + "/fin_payment", "/fin_settlement", "/fin_reconciliation", + "/cms_content", "/cs_ticket", } { assertRouteMethods(t, routes, "/heqi/platform/v1"+resource, http.MethodGet, http.MethodPost) assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete) diff --git a/backend/api/scripts/build.sh b/backend/api/scripts/build.sh index cbbd232..e366087 100644 --- a/backend/api/scripts/build.sh +++ b/backend/api/scripts/build.sh @@ -2,4 +2,4 @@ set -euo pipefail mkdir -p build GOARCH=amd64 GOOS=linux go build -o ./build/platform-api ./cmd/main/main.go -GOARCH=amd64 GOOS=linux go build -o ./build/platform-cli ./cmd/cli/main.go +GOARCH=amd64 GOOS=linux go build -o ./build/platform-cli ./cmd/cli