fix platform workflow integrity and permissions
This commit is contained in:
@@ -42,7 +42,7 @@ func UpdateRecordStatus(ctx *gin.Context, model any) {
|
||||
var request struct {
|
||||
Status int `json:"status" binding:"required"`
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil || !IsGenericRecordStatus(request.Status) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
@@ -55,7 +55,22 @@ func ArchiveRecord(ctx *gin.Context, model any) {
|
||||
}
|
||||
|
||||
func NewEntity(status int) models.Entity {
|
||||
return models.Entity{Identity: models.NewIdentity(), Status: status, Version: 1}
|
||||
return models.Entity{Identity: models.NewIdentity(), Status: status}
|
||||
}
|
||||
|
||||
// IsGenericRecordStatus reports whether status belongs to the shared record lifecycle.
|
||||
func IsGenericRecordStatus(status int) bool {
|
||||
switch status {
|
||||
case StatusDraft, StatusEnable, StatusDisable, StatusArchived, StatusFrozen:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveRecords excludes logically archived records from operational queries.
|
||||
func ActiveRecords(query *gorm.DB) *gorm.DB {
|
||||
return query.Where("status <> ?", StatusArchived)
|
||||
}
|
||||
|
||||
func ListPage[T any](ctx *gin.Context) {
|
||||
@@ -63,7 +78,7 @@ func ListPage[T any](ctx *gin.Context) {
|
||||
var list []T
|
||||
var total int64
|
||||
model := new(T)
|
||||
databaseQuery := ApplyKeywordFilter(ctx, impl.DBService.Model(model), model)
|
||||
databaseQuery := ApplyKeywordFilter(ctx, ActiveRecords(impl.DBService.Model(model)), model)
|
||||
if err := databaseQuery.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
|
||||
@@ -37,7 +37,7 @@ func ListResource(ctx *gin.Context, model any) {
|
||||
page, size := PageSize(ctx)
|
||||
list := reflect.New(reflect.SliceOf(reflect.TypeOf(model).Elem()))
|
||||
var total int64
|
||||
query := ApplyKeywordFilter(ctx, impl.DBService.Model(model), model)
|
||||
query := ApplyKeywordFilter(ctx, ActiveRecords(impl.DBService.Model(model)), model)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
@@ -74,6 +74,10 @@ func createResource(ctx *gin.Context, model any, allowedFields []string, relatio
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if err := ValidateResourceValues(model, values, true); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
encoded, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
@@ -123,7 +127,7 @@ func maskCreatedSensitiveFields(value any) any {
|
||||
|
||||
func isCreatedResponseField(key string) bool {
|
||||
switch key {
|
||||
case "identity", "status", "version", "created_at", "updated_at":
|
||||
case "identity", "status", "created_at", "updated_at":
|
||||
return true
|
||||
default:
|
||||
return strings.HasSuffix(key, "_identity")
|
||||
@@ -226,7 +230,7 @@ func updateResource(ctx *gin.Context, model any, allowedFields []string, relatio
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if len(values) == 0 {
|
||||
if len(values) == 0 || ValidateResourceValues(model, values, false) != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
@@ -245,6 +249,74 @@ func PrepareResourceValues(ctx *gin.Context, model any, allowedFields []string,
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// ValidateResourceValues enforces invariants that database nullability and
|
||||
// frontend form metadata cannot express.
|
||||
func ValidateResourceValues(model any, values map[string]any, creating bool) error {
|
||||
positive := func(key string) bool {
|
||||
value, exists := numericValue(values[key])
|
||||
return !exists || value > 0
|
||||
}
|
||||
nonNegative := func(key string) bool {
|
||||
value, exists := numericValue(values[key])
|
||||
return !exists || value >= 0
|
||||
}
|
||||
nonEmpty := func(key string) bool {
|
||||
value, exists := values[key]
|
||||
if !exists {
|
||||
return !creating
|
||||
}
|
||||
text, ok := value.(string)
|
||||
return ok && strings.TrimSpace(text) != ""
|
||||
}
|
||||
switch model.(type) {
|
||||
case *models.EcProduct:
|
||||
if !nonEmpty("product_code") || !nonEmpty("name") || !nonNegative("price_amount") || !nonNegative("stock_quantity") {
|
||||
return errors.New("invalid commerce product")
|
||||
}
|
||||
case *models.EcCart:
|
||||
if !positive("quantity") {
|
||||
return errors.New("invalid cart quantity")
|
||||
}
|
||||
case *models.EcOrder:
|
||||
if !nonEmpty("order_no") || !nonNegative("total_amount") {
|
||||
return errors.New("invalid order")
|
||||
}
|
||||
case *models.EcOrderItem:
|
||||
if !nonEmpty("product_snapshot") || !positive("quantity") || !nonNegative("sale_amount") {
|
||||
return errors.New("invalid order item")
|
||||
}
|
||||
case *models.EcReview:
|
||||
score, exists := numericValue(values["score"])
|
||||
if (exists && (score < 1 || score > 5)) || !nonEmpty("content") {
|
||||
return errors.New("invalid review")
|
||||
}
|
||||
case *models.FinPayment:
|
||||
if !positive("amount") || !nonEmpty("channel") {
|
||||
return errors.New("invalid payment")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func numericValue(value any) (float64, bool) {
|
||||
switch number := value.(type) {
|
||||
case float64:
|
||||
return number, true
|
||||
case float32:
|
||||
return float64(number), true
|
||||
case int:
|
||||
return float64(number), true
|
||||
case int64:
|
||||
return float64(number), true
|
||||
case uint:
|
||||
return float64(number), true
|
||||
case uint64:
|
||||
return float64(number), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) {
|
||||
values := FilterFields(input, allowedFields)
|
||||
for _, relation := range relations {
|
||||
@@ -286,7 +358,7 @@ func ResolveIdentityID(model any, identity string, required bool) (uint64, error
|
||||
return 0, nil
|
||||
}
|
||||
var related struct{ ID uint64 }
|
||||
if err := impl.DBService.Model(model).Select("id").Where("identity = ?", identity).First(&related).Error; err != nil {
|
||||
if err := impl.DBService.Model(model).Select("id").Where("identity = ? AND status <> ?", identity, StatusArchived).First(&related).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return related.ID, nil
|
||||
|
||||
@@ -2,9 +2,13 @@ package common
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) {
|
||||
@@ -14,6 +18,24 @@ func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationalQueriesExcludeArchivedRecords(t *testing.T) {
|
||||
sqlDatabase, _, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sqlDatabase.Close()
|
||||
database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||
return ActiveRecords(tx.Model(&models.EcProduct{})).Find(&[]models.EcProduct{})
|
||||
})
|
||||
if !strings.Contains(statement, `status <> 3`) {
|
||||
t.Fatalf("archive filter missing from operational query: %s", statement)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceResponseStripsInternalIDsRecursively(t *testing.T) {
|
||||
got := ResourceResponse(map[string]any{
|
||||
"id": uint64(1), "identity": "root",
|
||||
@@ -56,3 +78,33 @@ func TestCommonMethodModesRemainHTTPCompatible(t *testing.T) {
|
||||
t.Fatal("standard HTTP methods unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericStatusRejectsDomainLifecycleValues(t *testing.T) {
|
||||
for _, status := range []int{StatusDraft, StatusEnable, StatusDisable, StatusArchived, StatusFrozen} {
|
||||
if !IsGenericRecordStatus(status) {
|
||||
t.Fatalf("generic status %d was rejected", status)
|
||||
}
|
||||
}
|
||||
if IsGenericRecordStatus(StatusCompleted) {
|
||||
t.Fatal("business lifecycle status was accepted as generic entity status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommerceAndFinanceRejectInvalidAmounts(t *testing.T) {
|
||||
tests := []struct {
|
||||
model any
|
||||
values map[string]any
|
||||
}{
|
||||
{&models.EcProduct{}, map[string]any{"product_code": "p", "name": "P", "price_amount": -1.0}},
|
||||
{&models.EcCart{}, map[string]any{"quantity": 0.0}},
|
||||
{&models.EcOrder{}, map[string]any{"order_no": "o", "total_amount": -1.0}},
|
||||
{&models.EcOrderItem{}, map[string]any{"product_snapshot": "{}", "quantity": -1.0, "sale_amount": 1.0}},
|
||||
{&models.EcReview{}, map[string]any{"score": 6.0, "content": "bad"}},
|
||||
{&models.FinPayment{}, map[string]any{"channel": "wallet", "amount": -1.0}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if ValidateResourceValues(test.model, test.values, true) == nil {
|
||||
t.Fatalf("%T accepted invalid values %#v", test.model, test.values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,18 +38,3 @@ const (
|
||||
StatusSuccess = 37 // 成功
|
||||
StatusMatched = 38 // 已匹配
|
||||
)
|
||||
|
||||
var statusNames = map[int]string{
|
||||
StatusDraft: "draft", StatusEnable: "enabled", StatusDisable: "disabled", StatusArchived: "archived", StatusFrozen: "frozen",
|
||||
StatusPending: "pending", StatusActive: "active", StatusExpired: "expired", StatusTerminated: "terminated",
|
||||
StatusRecorded: "recorded", StatusBound: "bound", StatusCreated: "created", StatusOrdered: "ordered",
|
||||
StatusAssigned: "assigned", StatusFilling: "filling", StatusReady: "ready", StatusException: "exception",
|
||||
StatusCancelled: "cancelled", StatusCompleted: "completed", StatusPosted: "posted", StatusApproved: "approved",
|
||||
StatusRejected: "rejected", StatusScrapped: "scrapped", StatusInStock: "in_stock", StatusInTransit: "in_transit",
|
||||
StatusInUse: "in_use", StatusRepairing: "repairing", StatusOpen: "open", StatusDelivering: "delivering",
|
||||
StatusAwaitingConfirmation: "awaiting_confirmation",
|
||||
StatusPaid: "paid", StatusPublished: "published", StatusSuccess: "success", StatusMatched: "matched",
|
||||
}
|
||||
|
||||
// StatusName 返回状态整数对应的稳定英文名称,供审计快照字段使用。
|
||||
func StatusName(status int) string { return statusNames[status] }
|
||||
|
||||
@@ -58,12 +58,22 @@ func Login(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
extend := map[string]string{"username": account.Username, "display_name": account.DisplayName}
|
||||
if account.PlatformRoleCode == "root" {
|
||||
extend["location_scope"] = "precise"
|
||||
} else {
|
||||
var role models.PlatformRole
|
||||
if impl.DBService.Select("data_scope").Where("role_code = ? AND status = ?", account.PlatformRoleCode, common.StatusEnable).First(&role).Error == nil &&
|
||||
role.DataScope == "precise" {
|
||||
extend["location_scope"] = "precise"
|
||||
}
|
||||
}
|
||||
accessToken, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt(
|
||||
0,
|
||||
account.Identity,
|
||||
"platform_admin",
|
||||
account.PlatformRoleCode,
|
||||
map[string]string{"username": account.Username, "display_name": account.DisplayName},
|
||||
extend,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -99,7 +109,7 @@ func CurrentProfile(ctx *gin.Context) {
|
||||
menuCodes := make([]string, 0, len(menus))
|
||||
seenMenuCodes := make(map[string]bool, len(menus))
|
||||
for _, menu := range menus {
|
||||
codes := []string{menu.MenuCode}
|
||||
codes := []string{menu.Identity}
|
||||
if path := strings.Trim(menu.Path, "/"); path != "" {
|
||||
codes = append(codes, strings.Split(path, "/")[0])
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ec
|
||||
|
||||
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"
|
||||
@@ -8,6 +9,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type categoryRequest struct {
|
||||
ParentIdentity string `json:"parent_identity"`
|
||||
Name string `json:"name" binding:"required,max=128"`
|
||||
SortNo int `json:"sort_no"`
|
||||
}
|
||||
|
||||
type ecCategoryView struct {
|
||||
Identity string `json:"identity"`
|
||||
ParentIdentity string `json:"parent_identity,omitempty"`
|
||||
@@ -50,11 +57,12 @@ 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 {
|
||||
query := common.ActiveRecords(impl.DBService.Model(&models.EcCategory{}))
|
||||
if err := query.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 {
|
||||
if err := query.Order("sort_no asc, id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
@@ -79,3 +87,66 @@ func GetEcCategory(ctx *gin.Context) {
|
||||
}
|
||||
infra.Response.Success(ctx, views[0])
|
||||
}
|
||||
|
||||
func CreateEcCategory(ctx *gin.Context) {
|
||||
var request categoryRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
parentID, err := common.ResolveIdentityID(&models.EcCategory{}, request.ParentIdentity, false)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
category := models.EcCategory{Entity: common.NewEntity(common.StatusDraft), ParentID: parentID, Name: request.Name, SortNo: request.SortNo}
|
||||
if err := impl.DBService.Create(&category).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
common.RespondCreatedResource(ctx, category)
|
||||
}
|
||||
|
||||
func UpdateEcCategory(ctx *gin.Context) {
|
||||
var request categoryRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
var category models.EcCategory
|
||||
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&category).Error; err != nil {
|
||||
common.RespondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
parentID, err := common.ResolveIdentityID(&models.EcCategory{}, request.ParentIdentity, false)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
var rows []models.EcCategory
|
||||
if err := impl.DBService.Select("id", "parent_id").Find(&rows).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
parents := make(map[uint64]uint64, len(rows))
|
||||
for _, row := range rows {
|
||||
parents[row.ID] = row.ParentID
|
||||
}
|
||||
if wouldCreateCategoryCycle(category.ID, parentID, parents) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
common.UpdateAllowedByIdentity(ctx, &models.EcCategory{}, gin.H{"parent_id": parentID, "name": request.Name, "sort_no": request.SortNo}, []string{"parent_id", "name", "sort_no"})
|
||||
}
|
||||
|
||||
func wouldCreateCategoryCycle(categoryID, parentID uint64, parents map[uint64]uint64) bool {
|
||||
seen := map[uint64]bool{}
|
||||
for parentID != 0 {
|
||||
if parentID == categoryID || seen[parentID] {
|
||||
return true
|
||||
}
|
||||
seen[parentID] = true
|
||||
parentID = parents[parentID]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
16
backend/api/internal/logic/platform/ec/ec_test.go
Normal file
16
backend/api/internal/logic/platform/ec/ec_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package ec
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCategoryHierarchyRejectsCycles(t *testing.T) {
|
||||
parents := map[uint64]uint64{2: 1, 3: 2}
|
||||
if !wouldCreateCategoryCycle(1, 3, parents) {
|
||||
t.Fatal("moving a category under its descendant did not form a detected cycle")
|
||||
}
|
||||
if !wouldCreateCategoryCycle(2, 2, parents) {
|
||||
t.Fatal("self-parent was accepted")
|
||||
}
|
||||
if wouldCreateCategoryCycle(3, 1, parents) {
|
||||
t.Fatal("valid move to an ancestor was rejected")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
@@ -40,6 +41,9 @@ func rewriteSettlementSubject(ctx *gin.Context) error {
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
return err
|
||||
}
|
||||
if !validSettlementPeriod(input["period_start"], input["period_end"]) {
|
||||
return errors.New("invalid settlement period")
|
||||
}
|
||||
subjectType, _ := input["subject_type"].(string)
|
||||
identity, _ := input["subject_identity"].(string)
|
||||
var model any
|
||||
@@ -66,3 +70,14 @@ func rewriteSettlementSubject(ctx *gin.Context) error {
|
||||
ctx.Request.Body = io.NopCloser(bytes.NewReader(encoded))
|
||||
return nil
|
||||
}
|
||||
|
||||
func validSettlementPeriod(startValue, endValue any) bool {
|
||||
startText, startOK := startValue.(string)
|
||||
endText, endOK := endValue.(string)
|
||||
if !startOK || !endOK {
|
||||
return false
|
||||
}
|
||||
start, startErr := time.Parse(time.RFC3339, startText)
|
||||
end, endErr := time.Parse(time.RFC3339, endText)
|
||||
return startErr == nil && endErr == nil && end.After(start)
|
||||
}
|
||||
|
||||
12
backend/api/internal/logic/platform/fin/fin_test.go
Normal file
12
backend/api/internal/logic/platform/fin/fin_test.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package fin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSettlementPeriodMustMoveForward(t *testing.T) {
|
||||
if !validSettlementPeriod("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z") {
|
||||
t.Fatal("valid settlement period was rejected")
|
||||
}
|
||||
if validSettlementPeriod("2026-02-01T00:00:00Z", "2026-01-01T00:00:00Z") {
|
||||
t.Fatal("reversed settlement period was accepted")
|
||||
}
|
||||
}
|
||||
@@ -147,8 +147,12 @@ func CreateGasorderContract(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if !deliveryBelongsToGas(deliveryID, gasID) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
contract := models.GasorderContract{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusDraft, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusDraft},
|
||||
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,
|
||||
@@ -181,6 +185,12 @@ func UpdateGasorderContract(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
var contract models.GasorderContract
|
||||
if err := impl.DBService.Select("gas_basic_id").Where("identity = ?", ctx.Param("identity")).First(&contract).Error; err != nil ||
|
||||
!deliveryBelongsToGas(deliveryID, contract.GasBasicID) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
result := impl.DBService.Model(&models.GasorderContract{}).
|
||||
Where("identity = ? AND status = ?", ctx.Param("identity"), common.StatusDraft).
|
||||
Updates(map[string]any{"delivery_basic_id": deliveryID, "title": request.Title, "terms": request.Terms,
|
||||
@@ -280,7 +290,7 @@ func changeGasorderContract(ctx *gin.Context, action string, target int) {
|
||||
|
||||
func contractRevision(contract models.GasorderContract, action, reason, operatorIdentity, operatorName string) *models.GasorderContractRevision {
|
||||
return &models.GasorderContractRevision{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
|
||||
GasorderContractID: contract.ID, Action: action, ContractStatus: contract.Status,
|
||||
EffectiveAt: contract.EffectiveAt, ExpiredAt: contract.ExpiredAt,
|
||||
OperatorIdentity: operatorIdentity, OperatorName: operatorName, OccurredAt: time.Now(), Reason: reason,
|
||||
@@ -318,7 +328,7 @@ func BindGasorderContractProduct(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
binding := models.GasorderContractProduct{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusBound, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusBound},
|
||||
GasorderContractID: contract.ID, ProductInfoID: product.ID, ProductCode: product.Code,
|
||||
ProductTypeName: productType.Name, ProductParams: product.Params, UnitPrice: request.UnitPrice, BoundAt: time.Now(),
|
||||
}
|
||||
@@ -330,10 +340,17 @@ func BindGasorderContractProduct(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func UnbindGasorderContractProduct(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
|
||||
}
|
||||
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})
|
||||
Updates(gasorderUnbindUpdates(request.Reason, now))
|
||||
if result.Error != nil || result.RowsAffected != 1 {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
@@ -404,13 +421,6 @@ func CreateGasorderBasic(ctx *gin.Context) {
|
||||
!product.IsEnabled || product.Status == common.StatusScrapped || 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, []int{common.StatusCompleted, common.StatusCancelled}).
|
||||
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
|
||||
@@ -418,7 +428,7 @@ func CreateGasorderBasic(ctx *gin.Context) {
|
||||
return errors.New("invalid payable amount")
|
||||
}
|
||||
order = models.GasorderBasic{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusCreated, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusCreated},
|
||||
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,
|
||||
@@ -433,8 +443,9 @@ func CreateGasorderBasic(ctx *gin.Context) {
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
item := models.GasorderItem{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusOrdered, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusOrdered},
|
||||
GasorderBasicID: order.ID, GasorderContractProductID: binding.ID, ProductInfoID: binding.ProductInfoID,
|
||||
Active: true,
|
||||
ProductCode: binding.ProductCode, ProductTypeName: binding.ProductTypeName,
|
||||
ProductParams: binding.ProductParams, UnitPrice: binding.UnitPrice,
|
||||
}
|
||||
@@ -445,6 +456,10 @@ func CreateGasorderBasic(ctx *gin.Context) {
|
||||
return tx.Create(gasorderStatusRecord(order.ID, common.StatusDraft, common.StatusCreated, "order created", operatorIdentity, operatorName)).Error
|
||||
})
|
||||
if err != nil {
|
||||
if lookupErr := impl.DBService.Where("request_no = ?", request.RequestNo).First(&order).Error; lookupErr == nil {
|
||||
common.RespondCreatedResource(ctx, order)
|
||||
return
|
||||
}
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
@@ -481,6 +496,9 @@ func AssignGasorderBasic(ctx *gin.Context) {
|
||||
if order.Status != common.StatusCreated && order.Status != common.StatusAssigned {
|
||||
return errors.New("order cannot be assigned")
|
||||
}
|
||||
if !validGasorderAssignment(order, delivery, staff) {
|
||||
return errors.New("delivery or staff does not belong to the order organization")
|
||||
}
|
||||
previous := order.Status
|
||||
if err := tx.Model(&order).Updates(map[string]any{
|
||||
"delivery_basic_id": delivery.ID, "staff_account_id": staff.ID, "status": common.StatusAssigned,
|
||||
@@ -488,7 +506,7 @@ func AssignGasorderBasic(ctx *gin.Context) {
|
||||
return err
|
||||
}
|
||||
assignment := models.GasorderAssign{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
|
||||
GasorderBasicID: order.ID, GasBasicID: order.GasBasicID, DeliveryBasicID: delivery.ID,
|
||||
StaffAccountID: staff.ID, AssignerIdentity: operatorIdentity, AssignerName: operatorName,
|
||||
AssignedAt: time.Now(), Reason: request.Reason,
|
||||
@@ -509,20 +527,68 @@ func AssignGasorderBasic(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func GasorderStartFilling(ctx *gin.Context) {
|
||||
transitionGasorder(ctx, common.StatusFilling, map[int]bool{common.StatusAssigned: true})
|
||||
transitionGasorder(ctx, common.StatusFilling)
|
||||
}
|
||||
func GasorderReady(ctx *gin.Context) {
|
||||
transitionGasorder(ctx, common.StatusReady, map[int]bool{common.StatusFilling: true})
|
||||
transitionGasorder(ctx, common.StatusReady)
|
||||
}
|
||||
func GasorderCancel(ctx *gin.Context) {
|
||||
transitionGasorder(ctx, common.StatusCancelled, map[int]bool{common.StatusCreated: true, common.StatusAssigned: true})
|
||||
transitionGasorder(ctx, common.StatusCancelled)
|
||||
}
|
||||
func GasorderStartDelivering(ctx *gin.Context) {
|
||||
transitionGasorder(ctx, common.StatusDelivering)
|
||||
}
|
||||
func GasorderAwaitingConfirmation(ctx *gin.Context) {
|
||||
transitionGasorder(ctx, common.StatusAwaitingConfirmation)
|
||||
}
|
||||
|
||||
func GasorderComplete(ctx *gin.Context) {
|
||||
var request struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
ConfirmType string `json:"confirm_type" binding:"required,max=32"`
|
||||
RecipientName string `json:"recipient_name" binding:"required,max=64"`
|
||||
RecipientPhone string `json:"recipient_phone" binding:"max=32"`
|
||||
ProofURI string `json:"proof_uri" binding:"max=512"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
operatorIdentity, operatorName := common.PlatformOperator(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 != common.StatusAwaitingConfirmation {
|
||||
return errors.New("order cannot be completed")
|
||||
}
|
||||
if err := tx.Model(&order).Update("status", common.StatusCompleted).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
confirm := models.GasorderConfirm{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
|
||||
GasorderBasicID: order.ID, ConfirmType: request.ConfirmType, RecipientName: request.RecipientName,
|
||||
RecipientPhone: request.RecipientPhone, ProofURI: request.ProofURI, ConfirmedAt: time.Now(), Remark: request.Remark,
|
||||
}
|
||||
if err := tx.Create(&confirm).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := releaseGasorderProducts(tx, order.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(gasorderStatusRecord(order.ID, order.Status, common.StatusCompleted, request.Reason, operatorIdentity, operatorName)).Error
|
||||
})
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"updated": true, "status": common.StatusCompleted})
|
||||
}
|
||||
|
||||
func GasorderException(ctx *gin.Context) {
|
||||
transitionGasorder(ctx, common.StatusException, map[int]bool{
|
||||
common.StatusFilling: true, common.StatusReady: true,
|
||||
common.StatusDelivering: true, common.StatusAwaitingConfirmation: true,
|
||||
})
|
||||
transitionGasorder(ctx, common.StatusException)
|
||||
}
|
||||
|
||||
func GasorderRecover(ctx *gin.Context) {
|
||||
@@ -555,7 +621,7 @@ func GasorderRecover(ctx *gin.Context) {
|
||||
infra.Response.Success(ctx, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) {
|
||||
func transitionGasorder(ctx *gin.Context, target int) {
|
||||
var request struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
@@ -569,7 +635,7 @@ func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) {
|
||||
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 == common.StatusFilling && order.DeliveryBasicID == 0) {
|
||||
if !gasorderTransitionAllowed(order.Status, target) || (target == common.StatusFilling && order.DeliveryBasicID == 0) {
|
||||
return errors.New("invalid order transition")
|
||||
}
|
||||
updates := map[string]any{"status": target}
|
||||
@@ -579,6 +645,35 @@ func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) {
|
||||
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if target == common.StatusDelivering {
|
||||
var attempt int
|
||||
if err := tx.Model(&models.GasorderTrack{}).Where("gasorder_basic_id = ?", order.ID).
|
||||
Select("COALESCE(MAX(attempt_no), 0)").Scan(&attempt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
track := models.GasorderTrack{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusActive},
|
||||
GasorderBasicID: order.ID, StaffAccountID: order.StaffAccountID,
|
||||
AttemptNo: attempt + 1, StartedAt: time.Now(),
|
||||
}
|
||||
if err := tx.Create(&track).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if target == common.StatusAwaitingConfirmation {
|
||||
now := time.Now()
|
||||
result := tx.Model(&models.GasorderTrack{}).
|
||||
Where("gasorder_basic_id = ? AND completed_at IS NULL", order.ID).
|
||||
Update("completed_at", &now)
|
||||
if result.Error != nil || result.RowsAffected != 1 {
|
||||
return errors.New("active delivery track not found")
|
||||
}
|
||||
}
|
||||
if target == common.StatusCancelled {
|
||||
if err := releaseGasorderProducts(tx, order.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(gasorderStatusRecord(order.ID, order.Status, target, request.Reason, operatorIdentity, operatorName)).Error
|
||||
})
|
||||
if err != nil {
|
||||
@@ -588,9 +683,58 @@ func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) {
|
||||
infra.Response.Success(ctx, gin.H{"updated": true, "status": target})
|
||||
}
|
||||
|
||||
func gasorderTransitionAllowed(from, target int) bool {
|
||||
switch target {
|
||||
case common.StatusFilling:
|
||||
return from == common.StatusAssigned
|
||||
case common.StatusReady:
|
||||
return from == common.StatusFilling
|
||||
case common.StatusDelivering:
|
||||
return from == common.StatusReady
|
||||
case common.StatusAwaitingConfirmation:
|
||||
return from == common.StatusDelivering
|
||||
case common.StatusCompleted:
|
||||
return from == common.StatusAwaitingConfirmation
|
||||
case common.StatusCancelled:
|
||||
return from == common.StatusCreated || from == common.StatusAssigned
|
||||
case common.StatusException:
|
||||
return from == common.StatusFilling || from == common.StatusReady ||
|
||||
from == common.StatusDelivering || from == common.StatusAwaitingConfirmation
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func gasorderUnbindUpdates(reason string, now time.Time) map[string]any {
|
||||
return map[string]any{"status": common.StatusDisable, "unbound_at": &now, "unbind_reason": strings.TrimSpace(reason)}
|
||||
}
|
||||
|
||||
func releaseGasorderProducts(tx *gorm.DB, orderID uint64) error {
|
||||
return tx.Model(&models.GasorderItem{}).Where("gasorder_basic_id = ?", orderID).Update("active", false).Error
|
||||
}
|
||||
|
||||
func validGasorderAssignment(order models.GasorderBasic, delivery models.DeliveryBasic, staff models.StaffAccount) bool {
|
||||
if delivery.GasBasicID != 0 && delivery.GasBasicID != order.GasBasicID {
|
||||
return false
|
||||
}
|
||||
if staff.DeliveryBasicID != delivery.ID {
|
||||
return false
|
||||
}
|
||||
return staff.GasBasicID == 0 || staff.GasBasicID == order.GasBasicID
|
||||
}
|
||||
|
||||
func deliveryBelongsToGas(deliveryID, gasID uint64) bool {
|
||||
if deliveryID == 0 {
|
||||
return true
|
||||
}
|
||||
var delivery models.DeliveryBasic
|
||||
return impl.DBService.Select("gas_basic_id").First(&delivery, deliveryID).Error == nil &&
|
||||
(delivery.GasBasicID == 0 || delivery.GasBasicID == gasID)
|
||||
}
|
||||
|
||||
func gasorderStatusRecord(orderID uint64, from, to int, reason, operatorIdentity, operatorName string) *models.GasorderStatus {
|
||||
return &models.GasorderStatus{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
|
||||
GasorderBasicID: orderID, FromStatus: from, ToStatus: to,
|
||||
OperatorIdentity: operatorIdentity, OperatorName: operatorName, OccurredAt: time.Now(), Reason: reason,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package gasorder
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
@@ -18,6 +21,71 @@ func TestGasorderCreatorTypesCoverEveryConfirmedOrigin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbindWritesIntegerGenericStatusAndReason(t *testing.T) {
|
||||
updates := gasorderUnbindUpdates(" contract ended ", time.Now())
|
||||
status, ok := updates["status"].(int)
|
||||
if !ok || status != common.StatusDisable {
|
||||
t.Fatalf("unbind status = %#v, want integer disabled status", updates["status"])
|
||||
}
|
||||
if updates["unbind_reason"] != "contract ended" {
|
||||
t.Fatalf("unbind reason was not retained: %#v", updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGasorderCompleteStateMachine(t *testing.T) {
|
||||
path := []int{
|
||||
common.StatusCreated,
|
||||
common.StatusAssigned,
|
||||
common.StatusFilling,
|
||||
common.StatusReady,
|
||||
common.StatusDelivering,
|
||||
common.StatusAwaitingConfirmation,
|
||||
common.StatusCompleted,
|
||||
}
|
||||
for index := 1; index < len(path); index++ {
|
||||
if index == 1 {
|
||||
continue // assignment has organization validation and its own handler
|
||||
}
|
||||
if !gasorderTransitionAllowed(path[index-1], path[index]) {
|
||||
t.Fatalf("transition %d -> %d is not reachable", path[index-1], path[index])
|
||||
}
|
||||
}
|
||||
if gasorderTransitionAllowed(common.StatusReady, common.StatusCompleted) {
|
||||
t.Fatal("state machine allows skipping delivery and confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGasorderAssignmentRequiresSameOrganization(t *testing.T) {
|
||||
order := models.GasorderBasic{Entity: models.Entity{ID: 1}, GasBasicID: 10}
|
||||
delivery := models.DeliveryBasic{Entity: models.Entity{ID: 20}, GasBasicID: 10}
|
||||
staff := models.StaffAccount{GasBasicID: 10, DeliveryBasicID: 20}
|
||||
if !validGasorderAssignment(order, delivery, staff) {
|
||||
t.Fatal("valid organization assignment was rejected")
|
||||
}
|
||||
staff.DeliveryBasicID = 21
|
||||
if validGasorderAssignment(order, delivery, staff) {
|
||||
t.Fatal("cross-delivery staff assignment was accepted")
|
||||
}
|
||||
staff.DeliveryBasicID = 20
|
||||
delivery.GasBasicID = 11
|
||||
if validGasorderAssignment(order, delivery, staff) {
|
||||
t.Fatal("cross-gas delivery assignment was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGasorderProductHasConcurrentActiveReservationConstraint(t *testing.T) {
|
||||
field, ok := reflect.TypeOf(models.GasorderItem{}).FieldByName("ProductInfoID")
|
||||
if !ok {
|
||||
t.Fatal("ProductInfoID field missing")
|
||||
}
|
||||
tag := field.Tag.Get("gorm")
|
||||
for _, required := range []string{"uniqueIndex:idx_active_gasorder_product", "where:active = true"} {
|
||||
if !strings.Contains(tag, required) {
|
||||
t.Fatalf("active reservation constraint missing %q from %q", required, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGasorderStatusRecordIsImmutableSnapshot(t *testing.T) {
|
||||
record := gasorderStatusRecord(7, common.StatusAssigned, common.StatusFilling, "start filling", "operator-a", "Operator")
|
||||
if record.GasorderBasicID != 7 || record.FromStatus != common.StatusAssigned || record.ToStatus != common.StatusFilling {
|
||||
|
||||
@@ -53,10 +53,10 @@ var PlatformMenus = [][]Menu{
|
||||
{Identity: "product_info", ParentIdentity: "device", MenuCode: "device", Name: "产品信息", Path: "/product/product-info", SortNo: 3, Status: common.StatusEnable},
|
||||
},
|
||||
{
|
||||
{Identity: "gasorder", MenuCode: "delivery", Name: "气体配送订单管理", Icon: "icon-list", Path: "/gasorder", SortNo: 70, Status: common.StatusEnable},
|
||||
{Identity: "gasorder_contract", ParentIdentity: "gasorder", MenuCode: "delivery", Name: "合同管理", Path: "/gasorder/contracts", SortNo: 1, Status: common.StatusEnable},
|
||||
{Identity: "gasorder_basic", ParentIdentity: "gasorder", MenuCode: "delivery", Name: "配送订单", Path: "/gasorder/orders", SortNo: 2, Status: common.StatusEnable},
|
||||
{Identity: "gasorder_track", ParentIdentity: "gasorder", MenuCode: "delivery", Name: "运行轨迹", Path: "/gasorder/tracks", SortNo: 3, Status: common.StatusEnable},
|
||||
{Identity: "gasorder", MenuCode: "gasorder", Name: "气体配送订单管理", Icon: "icon-list", Path: "/gasorder", SortNo: 70, Status: common.StatusEnable},
|
||||
{Identity: "gasorder_contract", ParentIdentity: "gasorder", MenuCode: "gasorder_contract", Name: "合同管理", Path: "/gasorder/contracts", SortNo: 1, Status: common.StatusEnable},
|
||||
{Identity: "gasorder_basic", ParentIdentity: "gasorder", MenuCode: "gasorder_basic", Name: "配送订单", Path: "/gasorder/orders", SortNo: 2, Status: common.StatusEnable},
|
||||
{Identity: "gasorder_track", ParentIdentity: "gasorder", MenuCode: "gasorder_track", Name: "运行轨迹", Path: "/gasorder/tracks", SortNo: 3, Status: common.StatusEnable},
|
||||
},
|
||||
{
|
||||
{Identity: "ec", MenuCode: "ec", Name: "商城管理", Icon: "icon-gift", Path: "/ec", SortNo: 80, Status: common.StatusEnable},
|
||||
@@ -127,19 +127,24 @@ func LoadPlatformMenus(roleCode string) ([]Menu, error) {
|
||||
if err := impl.DBService.Where("role_code = ? AND status = ?", roleCode, common.StatusEnable).First(&role).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var codes []string
|
||||
var identities []string
|
||||
if err := impl.DBService.Model(&models.PlatformRoleMenu{}).
|
||||
Where("platform_role_id = ?", role.ID).
|
||||
Pluck("menu_code", &codes).Error; err != nil {
|
||||
Pluck("menu_identity", &identities).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(codes))
|
||||
for _, code := range codes {
|
||||
allowed[code] = struct{}{}
|
||||
allowed := make(map[string]struct{}, len(identities))
|
||||
for _, identity := range identities {
|
||||
allowed[identity] = struct{}{}
|
||||
}
|
||||
for _, menu := range menus {
|
||||
if _, ok := allowed[menu.Identity]; ok && menu.ParentIdentity != "" {
|
||||
allowed[menu.ParentIdentity] = struct{}{}
|
||||
}
|
||||
}
|
||||
filtered := make([]Menu, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
if _, ok := allowed[menu.MenuCode]; ok {
|
||||
if _, ok := allowed[menu.Identity]; ok {
|
||||
filtered = append(filtered, menu)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,36 +19,35 @@ func platformMenuAllowsPath(menus []platformbase.Menu, requestPath string) bool
|
||||
}
|
||||
relative := strings.Trim(requestPath[index+len(marker):], "/")
|
||||
resource := strings.Split(relative, "/")[0]
|
||||
domain := platformRouteDomain(resource)
|
||||
menuIdentity := platformRouteMenuIdentity(resource)
|
||||
for _, menu := range menus {
|
||||
if menu.MenuCode == domain {
|
||||
return true
|
||||
}
|
||||
menuPath := strings.Trim(menu.Path, "/")
|
||||
if menuPath != "" && strings.Split(menuPath, "/")[0] == domain {
|
||||
if menu.Identity == menuIdentity {
|
||||
return true
|
||||
}
|
||||
}
|
||||
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 "platform":
|
||||
return "platform"
|
||||
func platformRouteMenuIdentity(resource string) string {
|
||||
switch {
|
||||
case resource == "dashboard":
|
||||
return "dashboard_overview"
|
||||
case strings.HasPrefix(resource, "gasorder_contract"):
|
||||
return "gasorder_contract"
|
||||
case resource == "gasorder_track" || resource == "gasorder_track_point":
|
||||
return "gasorder_track"
|
||||
case strings.HasPrefix(resource, "gasorder_"):
|
||||
return "gasorder_basic"
|
||||
case resource == "product_type" || resource == "product_warehouse":
|
||||
return resource
|
||||
case strings.HasPrefix(resource, "product_"):
|
||||
return "product_info"
|
||||
case strings.HasPrefix(resource, "cms_"):
|
||||
return "cms_content"
|
||||
case strings.HasPrefix(resource, "cs_"):
|
||||
return "cs_ticket"
|
||||
default:
|
||||
return prefix
|
||||
return resource
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
30
backend/api/internal/logic/platform/platform/access_test.go
Normal file
30
backend/api/internal/logic/platform/platform/access_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
platformbase "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform"
|
||||
)
|
||||
|
||||
func TestSecondLevelMenuPermissionDoesNotGrantSibling(t *testing.T) {
|
||||
menus := []platformbase.Menu{{Identity: "delivery_basic"}}
|
||||
if !platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_basic") {
|
||||
t.Fatal("selected second-level menu did not grant its resource")
|
||||
}
|
||||
if platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_account") {
|
||||
t.Fatal("selected second-level menu granted a sibling resource")
|
||||
}
|
||||
if platformMenuAllowsPath(menus, "/heqi/platform/v1/gasorder_basic") {
|
||||
t.Fatal("delivery permission leaked into gasorder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenGasorderResourcesFollowOwningSecondLevelMenu(t *testing.T) {
|
||||
orderMenus := []platformbase.Menu{{Identity: "gasorder_basic"}}
|
||||
if !platformMenuAllowsPath(orderMenus, "/heqi/platform/v1/gasorder_confirm") {
|
||||
t.Fatal("order confirmation was not covered by order menu")
|
||||
}
|
||||
if platformMenuAllowsPath(orderMenus, "/heqi/platform/v1/gasorder_contract") {
|
||||
t.Fatal("order menu granted contract management")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package platform
|
||||
import (
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"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"
|
||||
@@ -15,7 +16,7 @@ func ListPlatformAccount(ctx *gin.Context) {
|
||||
page, size := common.PageSize(ctx)
|
||||
var list []models.PlatformAccount
|
||||
var total int64
|
||||
query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(&models.PlatformAccount{}), &models.PlatformAccount{})
|
||||
query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(&models.PlatformAccount{})), &models.PlatformAccount{})
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
@@ -98,6 +99,15 @@ func UpdatePlatformAccount(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
claims, err := middleware.ParseAuth(ctx)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
if claims.Role != "root" && claims.Identity != ctx.Param("identity") {
|
||||
infra.Response.Error(ctx, errcode.ErrPermissionDenied)
|
||||
return
|
||||
}
|
||||
values := gin.H{"display_name": request.DisplayName, "avatar": request.Avatar, "phone": request.Phone}
|
||||
if request.PlatformRoleCode != nil {
|
||||
if !common.RequirePlatformRoot(ctx) {
|
||||
|
||||
@@ -36,19 +36,21 @@ func ReplacePlatformRoleMenus(ctx *gin.Context) {
|
||||
if role.IsSystem {
|
||||
return errSystemPlatformRole
|
||||
}
|
||||
menuCodes := make(map[string]struct{}, len(request.MenuIdentities))
|
||||
menuIdentities := make(map[string]struct{}, len(request.MenuIdentities))
|
||||
for _, identity := range request.MenuIdentities {
|
||||
menu, ok := platformbase.FindPlatformMenu(identity)
|
||||
if !ok {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
menuCodes[menu.MenuCode] = struct{}{}
|
||||
if menu.ParentIdentity != "" {
|
||||
menuIdentities[menu.Identity] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := transaction.Where("platform_role_id = ?", role.ID).Delete(&models.PlatformRoleMenu{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for menuCode := range menuCodes {
|
||||
relation := models.PlatformRoleMenu{PlatformRoleID: role.ID, MenuCode: menuCode}
|
||||
for menuIdentity := range menuIdentities {
|
||||
relation := models.PlatformRoleMenu{PlatformRoleID: role.ID, MenuIdentity: menuIdentity}
|
||||
if err := transaction.Create(&relation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -79,22 +81,12 @@ func ListPlatformRoleMenuIdentities(ctx *gin.Context) {
|
||||
common.RespondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
var codes []string
|
||||
var identities []string
|
||||
if err := impl.DBService.Model(&models.PlatformRoleMenu{}).
|
||||
Where("platform_role_id = ?", role.ID).
|
||||
Pluck("menu_code", &codes).Error; err != nil {
|
||||
Pluck("menu_identity", &identities).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
assigned := make(map[string]struct{}, len(codes))
|
||||
for _, code := range codes {
|
||||
assigned[code] = struct{}{}
|
||||
}
|
||||
identities := make([]string, 0, len(codes))
|
||||
for _, menu := range platformbase.AllPlatformMenus() {
|
||||
if _, ok := assigned[menu.MenuCode]; ok {
|
||||
identities = append(identities, menu.Identity)
|
||||
}
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"menu_identities": identities})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var productOwnerActions = map[string]bool{
|
||||
@@ -43,7 +44,7 @@ func createProductInfo(ctx *gin.Context, fields []string, relations []common.Res
|
||||
delete(values, "reason")
|
||||
delete(values, "remark")
|
||||
data := models.ProductInfo{Entity: common.NewEntity(common.StatusPending), Params: "{}", IsEnabled: false}
|
||||
if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || data.ProducedAt.IsZero() {
|
||||
if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || data.ProducedAt.IsZero() || !validProductOwnership(data) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
@@ -67,6 +68,8 @@ func createProductInfo(ctx *gin.Context, fields []string, relations []common.Res
|
||||
common.RespondCreatedResource(ctx, &data)
|
||||
}
|
||||
|
||||
func canStartProductRepair(pendingCount int64) bool { return pendingCount == 0 }
|
||||
|
||||
func updateProductInfo(ctx *gin.Context, fields []string, relations []common.ResourceRelation) {
|
||||
var input map[string]any
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
@@ -114,6 +117,13 @@ func updateProductInfo(ctx *gin.Context, fields []string, relations []common.Res
|
||||
}
|
||||
}
|
||||
}
|
||||
if enabled, ok := values["is_enabled"].(bool); ok && !enabled {
|
||||
values["status"] = common.StatusPending
|
||||
}
|
||||
preview := current
|
||||
if err := decodeValues(values, &preview); err != nil || !validProductOwnership(preview) {
|
||||
return errors.New("product can have at most one current owner")
|
||||
}
|
||||
ownershipChanged := ownershipValuesChanged(current, values)
|
||||
if ownershipChanged && !productOwnerActions[action] {
|
||||
return errors.New("invalid ownership action")
|
||||
@@ -136,18 +146,15 @@ func updateProductInfo(ctx *gin.Context, fields []string, relations []common.Res
|
||||
infra.Response.Success(ctx, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
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) { common.GetResource(ctx, &models.ProductOwner{}) }
|
||||
func ProductOwnerHandlers() (gin.HandlerFunc, gin.HandlerFunc) {
|
||||
return listProductOwners, func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductOwner{}) }
|
||||
}
|
||||
|
||||
func listProductOwners(ctx *gin.Context) {
|
||||
page, size := common.PageSize(ctx)
|
||||
var list []models.ProductOwner
|
||||
var total int64
|
||||
query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(&models.ProductOwner{}), &models.ProductOwner{})
|
||||
query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(&models.ProductOwner{})), &models.ProductOwner{})
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
@@ -172,7 +179,11 @@ func UpdateProductInfoStatus(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
common.UpdateAllowedByIdentity(ctx, &models.ProductInfo{}, gin.H{"status": request.Status}, []string{"status"})
|
||||
values := gin.H{"status": request.Status}
|
||||
if request.Status == common.StatusScrapped {
|
||||
values["is_enabled"] = false
|
||||
}
|
||||
common.UpdateAllowedByIdentity(ctx, &models.ProductInfo{}, values, []string{"status", "is_enabled"})
|
||||
}
|
||||
|
||||
func ProductRepairHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
|
||||
@@ -195,6 +206,14 @@ func createProductRepair(ctx *gin.Context, fields []string, relations []common.R
|
||||
return
|
||||
}
|
||||
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var product models.ProductInfo
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, data.ProductInfoID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var pending int64
|
||||
if err := tx.Model(&models.ProductRepair{}).Where("product_info_id = ? AND result = ?", data.ProductInfoID, "pending").Count(&pending).Error; err != nil || !canStartProductRepair(pending) {
|
||||
return errors.New("product already has a pending repair")
|
||||
}
|
||||
if err := tx.Create(&data).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -239,6 +258,19 @@ func updateProductRepair(ctx *gin.Context, fields []string, relations []common.R
|
||||
return err
|
||||
}
|
||||
if current.Result == "pending" && preview.Result != "pending" {
|
||||
var product models.ProductInfo
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, preview.ProductInfoID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var remaining int64
|
||||
if err := tx.Model(&models.ProductRepair{}).
|
||||
Where("product_info_id = ? AND result = ? AND id <> ?", preview.ProductInfoID, "pending", current.ID).
|
||||
Count(&remaining).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining != 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&models.ProductInfo{}).Where("id = ?", preview.ProductInfoID).Update("status", preview.TargetStatus).Error
|
||||
}
|
||||
return nil
|
||||
@@ -265,30 +297,6 @@ func validRepair(repair models.ProductRepair) bool {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
action, _ := values["action"].(string)
|
||||
if action != "manual" {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
data := models.ProductOwner{Entity: common.NewEntity(common.StatusRecorded)}
|
||||
if err := decodeValues(values, &data); err != nil || data.ProductInfoID == 0 || data.OccurredAt.IsZero() {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
data.OperatorIdentity, data.OperatorName = productOperator(ctx)
|
||||
if err := impl.DBService.Create(&data).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
common.RespondCreatedResource(ctx, &data)
|
||||
}
|
||||
|
||||
func validParamsText(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
@@ -319,6 +327,16 @@ func initialProductStatus(product models.ProductInfo) int {
|
||||
return common.StatusPending
|
||||
}
|
||||
|
||||
func validProductOwnership(product models.ProductInfo) bool {
|
||||
count := 0
|
||||
for _, id := range []uint64{product.WarehouseID, product.GasBasicID, product.DeliveryBasicID, product.UserAccountID} {
|
||||
if id != 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count <= 1
|
||||
}
|
||||
|
||||
func ownershipValuesChanged(current models.ProductInfo, values map[string]any) bool {
|
||||
for key, old := range map[string]uint64{
|
||||
"warehouse_id": current.WarehouseID, "gas_basic_id": current.GasBasicID,
|
||||
@@ -357,7 +375,7 @@ func intValue(value any) (int, bool) {
|
||||
|
||||
func newProductOwner(product models.ProductInfo, action, reason, remark, operatorIdentity, operatorName string, occurredAt time.Time) *models.ProductOwner {
|
||||
return &models.ProductOwner{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
|
||||
ProductInfoID: product.ID, WarehouseID: product.WarehouseID, GasBasicID: product.GasBasicID,
|
||||
DeliveryBasicID: product.DeliveryBasicID, UserAccountID: product.UserAccountID,
|
||||
Action: action, OccurredAt: occurredAt, Reason: reason, Remark: remark,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package product
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +23,21 @@ func TestValidParamsTextRequiresJSONObjectText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyOnePendingRepairCanStart(t *testing.T) {
|
||||
if !canStartProductRepair(0) {
|
||||
t.Fatal("first pending repair was rejected")
|
||||
}
|
||||
if canStartProductRepair(1) {
|
||||
t.Fatal("second concurrent pending repair was accepted")
|
||||
}
|
||||
field, _ := reflect.TypeOf(models.ProductRepair{}).FieldByName("ProductInfoID")
|
||||
tag := field.Tag.Get("gorm")
|
||||
if !strings.Contains(tag, "uniqueIndex:idx_pending_product_repair") ||
|
||||
!strings.Contains(tag, "where:result = 'pending'") {
|
||||
t.Fatalf("database pending-repair constraint missing: %q", tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialProductStatusPrefersActualWarehouse(t *testing.T) {
|
||||
if got := initialProductStatus(models.ProductInfo{WarehouseID: 1, UserAccountID: 2}); got != common.StatusInStock {
|
||||
t.Fatalf("warehouse product status = %d", got)
|
||||
@@ -59,3 +76,12 @@ func TestOwnershipValuesChangedOnlyForActualDifference(t *testing.T) {
|
||||
t.Fatal("changed ownership was not detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductHasAtMostOneCurrentOwner(t *testing.T) {
|
||||
if !validProductOwnership(models.ProductInfo{WarehouseID: 1}) {
|
||||
t.Fatal("single warehouse owner was rejected")
|
||||
}
|
||||
if validProductOwnership(models.ProductInfo{WarehouseID: 1, UserAccountID: 2}) {
|
||||
t.Fatal("conflicting warehouse and user owners were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func ExpectedResources() []ResourceContract {
|
||||
resourceContract("delivery", "delivery_basic", Writable, "list"), resourceContract("delivery", "delivery_account", Writable, "list"),
|
||||
resourceContract("staff", "staff_account", Writable, "list"), resourceContract("staff", "staff_credential", Writable, "list"),
|
||||
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("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", ReadOnly, "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("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"),
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type addressRequest struct {
|
||||
@@ -31,7 +32,14 @@ func CreateUserAddress(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
address := models.UserAddress{Entity: common.NewEntity(common.StatusEnable), UserAccountID: userAccountID, Address: request.Address, Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: request.IsDefault}
|
||||
if err := impl.DBService.Create(&address).Error; err != nil {
|
||||
if err := impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
if request.IsDefault {
|
||||
if err := tx.Model(&models.UserAddress{}).Where("user_account_id = ?", userAccountID).Update("is_default", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&address).Error
|
||||
}); err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
@@ -48,7 +56,25 @@ func UpdateUserAddress(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
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"})
|
||||
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var current models.UserAddress
|
||||
if err := tx.Where("identity = ?", ctx.Param("identity")).First(¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if request.IsDefault {
|
||||
if err := tx.Model(&models.UserAddress{}).
|
||||
Where("user_account_id = ? AND id <> ?", userAccountID, current.ID).
|
||||
Update("is_default", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Model(¤t).Updates(gin.H{"user_account_id": userAccountID, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude, "is_default": request.IsDefault}).Error
|
||||
})
|
||||
if err != nil {
|
||||
common.RespondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
type serviceRelationRequest struct {
|
||||
|
||||
@@ -43,7 +43,7 @@ func listWalletPage[T any](ctx *gin.Context) {
|
||||
var list []T
|
||||
var total int64
|
||||
model := new(T)
|
||||
query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(model), model)
|
||||
query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(model)), model)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
@@ -133,7 +133,7 @@ func GetOrCreateOwnerWallet(ctx *gin.Context) {
|
||||
return err
|
||||
}
|
||||
candidate := models.WalletBasic{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable},
|
||||
OwnerType: ownerType, OwnerID: ownerID, OwnerIdentity: ownerIdentity,
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&candidate).Error; err != nil {
|
||||
@@ -227,7 +227,7 @@ func RechargeWalletBasic(ctx *gin.Context) {
|
||||
}
|
||||
now := time.Now()
|
||||
record = models.WalletRecord{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusPosted, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusPosted},
|
||||
WalletBasicID: wallet.ID, RecordNo: models.NewIdentity(), RequestNo: request.RequestNo,
|
||||
Direction: "income", TradeType: "recharge", Amount: request.Amount,
|
||||
BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance,
|
||||
@@ -239,6 +239,15 @@ func RechargeWalletBasic(ctx *gin.Context) {
|
||||
return tx.Create(&record).Error
|
||||
})
|
||||
if err != nil {
|
||||
if lookupErr := impl.DBService.Where("request_no = ?", request.RequestNo).First(&record).Error; lookupErr == nil {
|
||||
response, responseErr := common.PublicResourceResponse(record)
|
||||
if responseErr != nil {
|
||||
infra.Response.Error(ctx, responseErr)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, response)
|
||||
return
|
||||
}
|
||||
common.RespondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user