已完成用户APP首期功能开发

交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
czl231
2026-09-13 00:57:32 +08:00
parent a0a4bb1218
commit 3ef33b531d
793 changed files with 40959 additions and 1204 deletions

View File

@@ -0,0 +1,51 @@
// 功能描述:平台治理编辑不能改变合同申请的用户和分类关联;版本:1.0.0。
package platform
import (
"bytes"
"encoding/json"
"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/models"
"github.com/gin-gonic/gin"
"io"
)
// GuardContractTicketUpdate 保留普通工单编辑;合同申请允许治理字段编辑但禁止转移个人资料。
func GuardContractTicketUpdate(next gin.HandlerFunc) gin.HandlerFunc {
return func(ctx *gin.Context) {
var ticket models.CsTicket
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&ticket).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
if ticket.GasorderContractID == 0 {
next(ctx)
return
}
body, err := io.ReadAll(io.LimitReader(ctx.Request.Body, 65537))
if err != nil || len(body) > 65536 {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var input map[string]any
if json.Unmarshal(body, &input) != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if value, found := input["category"]; found && value != "contract_change" {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if value, found := input["user_account_identity"]; found {
var owner models.UserAccount
if err := impl.DBService.First(&owner, ticket.UserAccountID).Error; err != nil || value != owner.Identity {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
}
ctx.Request.Body = io.NopCloser(bytes.NewReader(body))
next(ctx)
}
}

View File

@@ -0,0 +1,62 @@
// 功能描述:平台编辑保留合同申请归属并保持请求体可读;版本:1.0.0。
package platform
import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"net/http/httptest"
"strings"
"testing"
)
func TestContractTicketGuardPreservesOwner(t *testing.T) {
for _, tc := range []struct {
body string
ownerQuery, allowed bool
}{
{`{"category":"repair"}`, false, false},
{`{"user_account_identity":"other"}`, true, false},
{`{"user_account_identity":"owner","priority":"high","category":"contract_change"}`, true, true},
} {
t.Run(tc.body, func(t *testing.T) {
connection, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer connection.Close()
db, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
previous := impl.DBService
impl.DBService = db
defer func() { impl.DBService = previous }()
mock.ExpectQuery(`SELECT .* FROM "cs_ticket"`).WillReturnRows(sqlmock.NewRows([]string{"gasorder_contract_id", "user_account_id"}).AddRow(7, 1))
if tc.ownerQuery {
mock.ExpectQuery(`SELECT .* FROM "user_account"`).WillReturnRows(sqlmock.NewRows([]string{"identity"}).AddRow("owner"))
}
response := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(response)
ctx.Params = gin.Params{{Key: "identity", Value: "ticket"}}
ctx.Request = httptest.NewRequest("PUT", "/cs_ticket/ticket", strings.NewReader(tc.body))
ctx.Request.Header.Set("Content-Type", "application/json")
called := false
GuardContractTicketUpdate(func(ctx *gin.Context) {
called = true
var body map[string]any
if ctx.ShouldBindJSON(&body) != nil || body["priority"] != "high" {
t.Error("原请求体丢失")
}
})(ctx)
if called != tc.allowed {
t.Fatalf("allowed=%v", called)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}

View File

@@ -0,0 +1,147 @@
// 功能描述:平台处理退瓶回收、验收扣减和押金入账;版本:1.0.0。
package platform
import (
"errors"
"time"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
sdkmiddleware "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"
"gorm.io/gorm/clause"
)
// ConfirmDepositPickup 确认实物已回收,进入待验收状态。
func ConfirmDepositPickup(ctx *gin.Context) {
transitionDepositReturn(ctx, 10, 20, func(request *models.DepositReturnRequest, now time.Time, operator string) map[string]any {
return map[string]any{"return_status": 20, "picked_up_at": &now, "operator_identity": operator}
})
}
// InspectDepositReturn 记录可审计的扣减金额和理由。
func InspectDepositReturn(ctx *gin.Context) {
var input struct {
DeductionAmount int64 `json:"deduction_amount" binding:"gte=0"`
Remark string `json:"remark" binding:"required,max=2000"`
}
if ctx.ShouldBindJSON(&input) != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
transitionDepositReturn(ctx, 20, 30, func(request *models.DepositReturnRequest, now time.Time, operator string) map[string]any {
if input.DeductionAmount > request.EstimatedAmount {
return nil
}
return map[string]any{
"return_status": 30, "deduction_amount": input.DeductionAmount,
"refund_amount": request.EstimatedAmount - input.DeductionAmount,
"inspection_remark": input.Remark, "inspected_at": &now, "operator_identity": operator,
}
})
}
// CompleteDepositRefund 在同一事务内退入余额、写不可变流水并完成押金状态。
func CompleteDepositRefund(ctx *gin.Context) {
claims, err := sdkmiddleware.ParseAuth(ctx)
if err != nil {
infra.Response.Error(ctx, errcode.ErrPermissionDenied)
return
}
now := time.Now()
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
var request models.DepositReturnRequest
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&request).Error; err != nil {
return err
}
if request.ReturnStatus == 40 {
return nil
}
if request.ReturnStatus != 30 {
return gorm.ErrInvalidData
}
var user models.UserAccount
if err := tx.First(&user, request.UserAccountID).Error; err != nil {
return err
}
var wallet models.WalletBasic
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("owner_type = ? AND owner_identity = ?", "user", user.Identity).First(&wallet).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
wallet = models.WalletBasic{Entity: common.NewEntity(common.StatusEnable), OwnerType: "user", OwnerID: user.ID, OwnerIdentity: user.Identity}
if err := tx.Create(&wallet).Error; err != nil {
return err
}
}
if request.RefundAmount > 0 {
wallet.Balance += request.RefundAmount
if err := tx.Model(&wallet).Update("balance", wallet.Balance).Error; err != nil {
return err
}
year, month, day := now.Date()
flow := models.WalletRecord{
Entity: common.NewEntity(common.StatusEnable), WalletBasicID: wallet.ID,
RecordNo: common.RecordNo("DR"), RequestNo: "deposit-refund:" + request.Identity,
Direction: "income", TradeType: "deposit_refund", Amount: request.RefundAmount,
BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance,
InTradeNo: request.Identity, PayChannel: "wallet", OperatorIdentity: claims.Identity,
Ymd: int32(year*10000 + int(month)*100 + day), Ym: int32(year*100 + int(month)), Remark: "退瓶验收后押金退回",
}
if err := tx.Create(&flow).Error; err != nil {
return err
}
}
depositUpdate := tx.Model(&models.DepositRecord{}).Where("id = ? AND deposit_status = ?", request.DepositRecordID, 20).
Updates(map[string]any{"deposit_status": 23, "refunded_at": &now})
if depositUpdate.Error != nil {
return depositUpdate.Error
}
if depositUpdate.RowsAffected != 1 {
return gorm.ErrInvalidData
}
return tx.Model(&request).Updates(map[string]any{"return_status": 40, "completed_at": &now, "operator_identity": claims.Identity}).Error
})
if err != nil {
if errors.Is(err, gorm.ErrInvalidData) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
common.RespondRecordError(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"completed": true})
}
func transitionDepositReturn(ctx *gin.Context, from, to int, values func(*models.DepositReturnRequest, time.Time, string) map[string]any) {
claims, err := sdkmiddleware.ParseAuth(ctx)
if err != nil {
infra.Response.Error(ctx, errcode.ErrPermissionDenied)
return
}
var request models.DepositReturnRequest
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND return_status = ?", ctx.Param("identity"), from).First(&request).Error; err != nil {
return err
}
updates := values(&request, time.Now(), claims.Identity)
if updates == nil {
return gorm.ErrInvalidData
}
updates["return_status"] = to
return tx.Model(&request).Updates(updates).Error
})
if err != nil {
if errors.Is(err, gorm.ErrInvalidData) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
common.RespondRecordError(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"changed": true, "return_status": to})
}

View File

@@ -147,6 +147,11 @@ func ServeGasorderContractAttachment(ctx *gin.Context) {
common.RespondRecordError(ctx, err)
return
}
ServeAuthorizedContractPDF(ctx, contract, operator)
}
// ServeAuthorizedContractPDF 输出已由调用方完成归属鉴权的合同;不得直接注册为路由。
func ServeAuthorizedContractPDF(ctx *gin.Context, contract models.GasorderContract, operator string) {
path, err := contractAttachmentPath(contract.FileURI, false)
if err != nil || !validStoredContractPDF(path) {
logContractAttachment(operator, contract.Identity, "download", "unavailable", contract.FileURI)

View File

@@ -48,6 +48,7 @@ var PlatformMenus = [][]Menu{
{Identity: "product_type", ParentIdentity: "device", GroupCode: "device", Name: "类型管理", Path: "/product/product-type", SortNo: 2, Status: common.StatusEnable},
{Identity: "product_warehouse", ParentIdentity: "device", GroupCode: "device", Name: "库房管理", Path: "/product/warehouse", SortNo: 3, Status: common.StatusEnable},
{Identity: "product_info", ParentIdentity: "device", GroupCode: "device", Name: "智能气阀管理", Path: "/product/product-info", SortNo: 4, Status: common.StatusEnable},
{Identity: "dev_usage_stat", ParentIdentity: "device", GroupCode: "device", Name: "用气统计数据", Path: "/product/usage-statistics", SortNo: 5, Status: common.StatusEnable},
},
{
{Identity: "gasorder", GroupCode: "gasorder", Name: "气体配送订单管理", Icon: "icon-list", Path: "/gasorder", SortNo: 70, Status: common.StatusEnable},
@@ -62,6 +63,9 @@ var PlatformMenus = [][]Menu{
{Identity: "ec_cart", ParentIdentity: "ec", GroupCode: "ec", Name: "购物车", Path: "/ec/carts", SortNo: 3, Status: common.StatusEnable},
{Identity: "ec_order", ParentIdentity: "ec", GroupCode: "ec", Name: "商城订单", Path: "/ec/orders", SortNo: 4, Status: common.StatusEnable},
{Identity: "ec_review", ParentIdentity: "ec", GroupCode: "ec", Name: "商品评价", Path: "/ec/reviews", SortNo: 5, Status: common.StatusEnable},
{Identity: "deposit_policy", ParentIdentity: "ec", GroupCode: "ec", Name: "押金规则", Path: "/ec/deposit-policies", SortNo: 6, Status: common.StatusEnable},
{Identity: "deposit_record", ParentIdentity: "ec", GroupCode: "ec", Name: "押金记录", Path: "/ec/deposit-records", SortNo: 7, Status: common.StatusEnable},
{Identity: "deposit_return_request", ParentIdentity: "ec", GroupCode: "ec", Name: "退瓶处理", Path: "/ec/deposit-returns", SortNo: 8, Status: common.StatusEnable},
},
{
{Identity: "finance", GroupCode: "finance", Name: "财务管理", Icon: "icon-bar-chart", Path: "/finance", SortNo: 100, Status: common.StatusEnable},

View File

@@ -93,6 +93,8 @@ func platformRouteMenuIdentity(resource string) string {
return resource
case strings.HasPrefix(resource, "product_"):
return "product_info"
case resource == "dev_usage_stat":
return "dev_usage_stat"
case strings.HasPrefix(resource, "cms_"):
return "cms_content"
case strings.HasPrefix(resource, "cs_"):

View File

@@ -0,0 +1,21 @@
// 功能:校验平台实体分类和厂商编号,禁止用名称推断设备能力;版本:1.0.0。
package product
import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"regexp"
)
var vendorDevicePattern = regexp.MustCompile(`^[0-9]{16}$`)
// validDeviceMapping 空编号允许先建立档案;钢瓶和未分类不能占用设备编号。
func validDeviceMapping(product models.ProductInfo) bool {
switch product.DeviceKind {
case "", "unknown", "cylinder":
return product.VendorDeviceID == ""
case "valve", "alarm":
return product.VendorDeviceID == "" || (vendorDevicePattern.MatchString(product.VendorDeviceID) && product.VendorDeviceID != "0000000000000000")
default:
return false
}
}

View File

@@ -0,0 +1,24 @@
// 功能:实体设备分类和厂商编号边界测试;版本:1.0.0。
package product
import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"testing"
)
// TestDeviceMappingBoundary 检查未知分类、普通钢瓶、非十进制和缺失编号。
func TestDeviceMappingBoundary(t *testing.T) {
for _, tc := range []struct {
kind, id string
valid bool
}{
{"unknown", "", true}, {"cylinder", "", true}, {"valve", "", true}, {"alarm", "1234567890123456", true},
{"valve", "0000000000000001", true}, {"cylinder", "0000000000000001", false},
{"unknown", "0000000000000001", false}, {"valve", "0000000000000000", false},
{"valve", "1234", false}, {"alarm", "123456789012345x", false}, {"invented", "", false},
} {
if got := validDeviceMapping(models.ProductInfo{DeviceKind: tc.kind, VendorDeviceID: tc.id}); got != tc.valid {
t.Errorf("分类%s编号%s结果%v", tc.kind, tc.id, got)
}
}
}

View File

@@ -1,3 +1,4 @@
// 功能:平台产品档案、厂商映射与生命周期事务;版本:1.1.0。
package product
import (
@@ -28,7 +29,7 @@ var productLifecycleStatuses = map[int]bool{
}
func ProductInfoHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
fields := []string{"code", "name", "params", "produced_at", "action", "reason", "remark"}
fields := []string{"code", "name", "params", "produced_at", "action", "reason", "remark", "device_kind", "vendor_device_id"}
return listProductInfo,
func(ctx *gin.Context) { createProductInfo(ctx, fields, relations) },
func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductInfo{}) },
@@ -61,11 +62,15 @@ func createProductInfo(ctx *gin.Context, fields []string, relations []common.Res
delete(values, "action")
delete(values, "reason")
delete(values, "remark")
data := models.ProductInfo{Entity: common.NewEntity(common.StatusDisable), ProductStatus: common.StatusPending, Params: "{}"}
data := models.ProductInfo{Entity: common.NewEntity(common.StatusDisable), ProductStatus: common.StatusPending, Params: "{}", DeviceKind: "unknown"}
if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || !validProductProducer(data) || data.ProducedAt.IsZero() || !validProductOwnership(data) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if !validDeviceMapping(data) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
operatorIdentity, operatorName := productOperator(ctx)
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&data).Error; err != nil {
@@ -112,10 +117,17 @@ func updateProductInfo(ctx *gin.Context, fields []string, relations []common.Res
if err := decodeValues(values, &preview); err != nil || !validProductProducer(preview) || !validProductOwnership(preview) {
return errors.New("product can have at most one current owner")
}
if !validDeviceMapping(preview) {
return errcode.ErrInvalidArgument
}
ownershipChanged := ownershipValuesChanged(current, values)
if ownershipChanged && !productOwnerActions[action] {
return errors.New("invalid ownership action")
}
if ownershipChanged {
// 设备分组只属于原用户;转移产品时必须解除归组,避免新用户继承旧用户结构。
values["device_group_id"] = uint64(0)
}
if err := tx.Model(&current).Updates(values).Error; err != nil {
return err
}

View File

@@ -82,8 +82,9 @@ 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", "producer_account", 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", ReadOnly, "list"),
resourceContract("product", "producer_account", 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", ReadOnly, "list"), resourceContract("product", "dev_usage_stat", Editable, "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", ReadOnly, "list"), resourceContract("ec", "ec_order", ReadOnly, "list"), resourceContract("ec", "ec_order_item", ReadOnly, "list"), resourceContract("ec", "ec_review", ReadOnly, "list"),
resourceContract("ec", "deposit_policy", Writable, "list"), resourceContract("ec", "deposit_record", ReadOnly, "list"), resourceContract("ec", "deposit_return_request", 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"),