From 550efb3812dbba38abf44e1b5b836d76f79796d6 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 30 Jul 2026 18:04:34 +0800 Subject: [PATCH] feat(api): add client app service endpoints --- backend/api/etc/platform_dev.yaml | 8 + backend/api/internal/config/config.go | 25 +- .../api/internal/logic/client/common/auth.go | 143 ++++++ .../logic/client/common/security_test.go | 43 ++ .../internal/logic/client/common/wallet.go | 476 ++++++++++++++++++ .../api/internal/logic/client/staff/auth.go | 123 +++++ .../api/internal/logic/client/staff/work.go | 226 +++++++++ .../logic/client/user/address_ticket.go | 165 ++++++ .../api/internal/logic/client/user/auth.go | 211 ++++++++ .../api/internal/logic/client/user/basic.go | 92 ++++ .../internal/logic/client/user/gasorder.go | 86 ++++ .../api/internal/logic/client/user/shop.go | 219 ++++++++ backend/api/internal/logic/upload/upload.go | 19 +- .../api/internal/models/cms_content_read.go | 22 + backend/api/internal/models/cs_ticket.go | 28 +- .../api/internal/models/cs_ticket_evidence.go | 26 + backend/api/internal/models/ec_order.go | 36 +- .../internal/models/gasorder_track_point.go | 16 +- backend/api/internal/models/staff_account.go | 18 +- .../api/internal/models/staff_attendance.go | 22 + backend/api/internal/models/user_account.go | 12 +- .../internal/models/wallet_recharge_order.go | 23 + backend/api/internal/routers/client.go | 86 ++++ backend/api/internal/routers/client_test.go | 34 ++ backend/api/internal/routers/register.go | 1 + backend/api/internal/routers/upload.go | 5 +- docs/03-用户端App需求.md | 8 + docs/04-服务端App需求.md | 9 + docs/11-数据接口与安全.md | 9 + 29 files changed, 2147 insertions(+), 44 deletions(-) create mode 100644 backend/api/internal/logic/client/common/auth.go create mode 100644 backend/api/internal/logic/client/common/security_test.go create mode 100644 backend/api/internal/logic/client/common/wallet.go create mode 100644 backend/api/internal/logic/client/staff/auth.go create mode 100644 backend/api/internal/logic/client/staff/work.go create mode 100644 backend/api/internal/logic/client/user/address_ticket.go create mode 100644 backend/api/internal/logic/client/user/auth.go create mode 100644 backend/api/internal/logic/client/user/basic.go create mode 100644 backend/api/internal/logic/client/user/gasorder.go create mode 100644 backend/api/internal/logic/client/user/shop.go create mode 100644 backend/api/internal/models/cms_content_read.go create mode 100644 backend/api/internal/models/cs_ticket_evidence.go create mode 100644 backend/api/internal/models/staff_attendance.go create mode 100644 backend/api/internal/models/wallet_recharge_order.go create mode 100644 backend/api/internal/routers/client.go create mode 100644 backend/api/internal/routers/client_test.go diff --git a/backend/api/etc/platform_dev.yaml b/backend/api/etc/platform_dev.yaml index 39e63c7..8660886 100644 --- a/backend/api/etc/platform_dev.yaml +++ b/backend/api/etc/platform_dev.yaml @@ -14,3 +14,11 @@ SecretKey: change-me-to-a-random-string Global: UserRegisterURL: http://localhost:5174/register ManualRechargeMaxAmount: 100000000 + MockVerificationEnabled: true + MockVerificationCode: "123456" + VerificationTTLSeconds: 300 + VerificationSendIntervalSeconds: 60 + MockPaymentEnabled: true + DeliveryArrivalRadiusMeters: 200 + UploadVideoMaxSize: 104857600 + FieldEncryptionKey: change-me-32-byte-development-key diff --git a/backend/api/internal/config/config.go b/backend/api/internal/config/config.go index ec661ab..10eef0e 100644 --- a/backend/api/internal/config/config.go +++ b/backend/api/internal/config/config.go @@ -4,6 +4,7 @@ package config import ( "net" "net/url" + "strings" "git.apinb.com/bsm-sdk/core/conf" ) @@ -13,8 +14,16 @@ var Spec SrvConfig // GlobalConfig 保存多个管理端共用的运行参数。 type GlobalConfig struct { - UserRegisterURL string `yaml:"UserRegisterURL"` - ManualRechargeMaxAmount int64 `yaml:"ManualRechargeMaxAmount"` + UserRegisterURL string `yaml:"UserRegisterURL"` + ManualRechargeMaxAmount int64 `yaml:"ManualRechargeMaxAmount"` + MockVerificationEnabled bool `yaml:"MockVerificationEnabled"` + MockVerificationCode string `yaml:"MockVerificationCode"` + VerificationTTLSeconds int `yaml:"VerificationTTLSeconds"` + VerificationSendIntervalSeconds int `yaml:"VerificationSendIntervalSeconds"` + MockPaymentEnabled bool `yaml:"MockPaymentEnabled"` + DeliveryArrivalRadiusMeters float64 `yaml:"DeliveryArrivalRadiusMeters"` + UploadVideoMaxSize int64 `yaml:"UploadVideoMaxSize"` + FieldEncryptionKey string `yaml:"FieldEncryptionKey"` } // WalletConfig 是平台既有钱包逻辑的内部兼容视图,值由 Global 注入。 @@ -42,6 +51,18 @@ func New(srvKey string) { if Spec.Global.ManualRechargeMaxAmount <= 0 { panic("Global.ManualRechargeMaxAmount must be greater than zero") } + if Spec.Global.VerificationTTLSeconds <= 0 || Spec.Global.VerificationSendIntervalSeconds <= 0 { + panic("Global verification timeouts must be greater than zero") + } + if Spec.Global.MockVerificationEnabled && len(Spec.Global.MockVerificationCode) < 4 { + panic("Global.MockVerificationCode must contain at least four characters") + } + if Spec.Global.DeliveryArrivalRadiusMeters <= 0 || Spec.Global.UploadVideoMaxSize <= 0 { + panic("Global delivery radius and upload video size must be greater than zero") + } + if len(strings.TrimSpace(Spec.Global.FieldEncryptionKey)) < 32 { + panic("Global.FieldEncryptionKey must contain at least 32 characters") + } Spec.Wallet.ManualRechargeMaxAmount = Spec.Global.ManualRechargeMaxAmount registerURL, err := url.ParseRequestURI(Spec.Global.UserRegisterURL) if err != nil || (registerURL.Scheme != "http" && registerURL.Scheme != "https") || registerURL.Host == "" { diff --git a/backend/api/internal/logic/client/common/auth.go b/backend/api/internal/logic/client/common/auth.go new file mode 100644 index 0000000..e2be14f --- /dev/null +++ b/backend/api/internal/logic/client/common/auth.go @@ -0,0 +1,143 @@ +// Package common 提供两个客户端共用的鉴权、验证码和账户范围能力。 +package common + +import ( + "fmt" + "regexp" + "strings" + "time" + + "git.apinb.com/bsm-sdk/core/crypto/token" + "git.apinb.com/bsm-sdk/core/env" + "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/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +var phonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`) + +var verificationPurposes = map[string]struct{}{ + "login": {}, "register": {}, "reset_login_password": {}, "set_payment_password": {}, + "reset_payment_password": {}, "bind_bank": {}, "unbind_bank": {}, +} + +type verificationValue struct { + Code string `json:"code"` + Phone string `json:"phone"` + Purpose string `json:"purpose"` + Client string `json:"client"` +} + +// ValidPhone 判断手机号是否符合中国大陆手机号格式。 +func ValidPhone(phone string) bool { return phonePattern.MatchString(strings.TrimSpace(phone)) } + +// SendVerificationCode 创建一次性验证码。Mock 模式的验证码只保存在 Redis,不返回给客户端。 +func SendVerificationCode(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + var request struct { + Phone string `json:"phone" binding:"required"` + Purpose string `json:"purpose" binding:"required"` + } + if ctx.ShouldBindJSON(&request) != nil || !ValidPhone(request.Phone) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + if _, ok := verificationPurposes[request.Purpose]; !ok { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + phone := strings.TrimSpace(request.Phone) + throttleKey := impl.RedisService.BuildKey("client-verification-throttle", client, phone) + var sent bool + if impl.RedisService.Get(throttleKey, &sent) == nil && sent { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + requestIdentity := models.NewIdentity() + value := verificationValue{Code: config.Spec.Global.MockVerificationCode, Phone: phone, Purpose: request.Purpose, Client: client} + ttl := time.Duration(config.Spec.Global.VerificationTTLSeconds) * time.Second + if err := impl.RedisService.Set(verificationKey(requestIdentity), value, ttl); err != nil { + infra.Response.Error(ctx, err) + return + } + _ = impl.RedisService.Set(throttleKey, true, time.Duration(config.Spec.Global.VerificationSendIntervalSeconds)*time.Second) + infra.Response.Success(ctx, gin.H{"request_identity": requestIdentity, "expires_in": config.Spec.Global.VerificationTTLSeconds}) + } +} + +// VerifyCode 校验并消费验证码。 +func VerifyCode(client, phone, purpose, requestIdentity, code string) bool { + if !config.Spec.Global.MockVerificationEnabled || requestIdentity == "" || code == "" { + return false + } + key := verificationKey(requestIdentity) + var value verificationValue + if impl.RedisService.Get(key, &value) != nil { + return false + } + if value.Client != client || value.Phone != strings.TrimSpace(phone) || value.Purpose != purpose || value.Code != code { + return false + } + return impl.RedisService.Delete(key) == nil +} + +func verificationKey(identity string) string { + return impl.RedisService.BuildKey("client-verification", identity) +} + +// IssueToken 签发严格区分 user_app 和 service_app 的 JWT。 +func IssueToken(identity, client, role string, extend map[string]string) (string, error) { + return token.New(env.Runtime.JwtSecretKey).GenerateJwt(0, identity, client, role, nil, extend) +} + +// RequireClient 验证客户端种类,阻止后台令牌跨端调用。 +func RequireClient(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + claims, err := sdkmiddleware.ParseAuth(ctx) + if err != nil || claims.Client != client { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + ctx.Abort() + return + } + ctx.Next() + } +} + +// UserAccount 返回当前启用的用户账户。 +func UserAccount(ctx *gin.Context) (models.UserAccount, bool) { + claims, err := sdkmiddleware.ParseAuth(ctx) + if err != nil { + infra.Response.Error(ctx, err) + return models.UserAccount{}, false + } + var account models.UserAccount + if impl.DBService.Where("identity = ? AND status = ?", claims.Identity, 1).First(&account).Error != nil { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + return models.UserAccount{}, false + } + return account, true +} + +// StaffAccount 返回当前启用的工作人员账户。 +func StaffAccount(ctx *gin.Context) (models.StaffAccount, bool) { + claims, err := sdkmiddleware.ParseAuth(ctx) + if err != nil { + infra.Response.Error(ctx, err) + return models.StaffAccount{}, false + } + var account models.StaffAccount + if impl.DBService.Where("identity = ? AND status = ?", claims.Identity, 1).First(&account).Error != nil { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + return models.StaffAccount{}, false + } + return account, true +} + +// RecordNo 生成便于检索的业务流水号。 +func RecordNo(prefix string) string { + return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano()) +} diff --git a/backend/api/internal/logic/client/common/security_test.go b/backend/api/internal/logic/client/common/security_test.go new file mode 100644 index 0000000..b927f4f --- /dev/null +++ b/backend/api/internal/logic/client/common/security_test.go @@ -0,0 +1,43 @@ +package common + +import ( + "testing" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" +) + +func TestValidPhone(t *testing.T) { + tests := map[string]bool{ + "13800138000": true, + "12800138000": false, + "1380013800": false, + "138001380000": false, + "": false, + } + for phone, want := range tests { + if got := ValidPhone(phone); got != want { + t.Errorf("ValidPhone(%q) = %v, want %v", phone, got, want) + } + } +} + +func TestProtectFieldUsesRandomCiphertextAndStableFingerprint(t *testing.T) { + original := config.Spec.Global.FieldEncryptionKey + config.Spec.Global.FieldEncryptionKey = "0123456789abcdef0123456789abcdef" + t.Cleanup(func() { config.Spec.Global.FieldEncryptionKey = original }) + + firstCipher, firstFingerprint, err := protectField("6222021234567890") + if err != nil { + t.Fatal(err) + } + secondCipher, secondFingerprint, err := protectField("6222021234567890") + if err != nil { + t.Fatal(err) + } + if firstCipher == secondCipher { + t.Fatal("AES-GCM ciphertext must use a fresh nonce") + } + if firstFingerprint != secondFingerprint { + t.Fatal("the same card number must have a stable HMAC fingerprint") + } +} diff --git a/backend/api/internal/logic/client/common/wallet.go b/backend/api/internal/logic/client/common/wallet.go new file mode 100644 index 0000000..eaf4dfa --- /dev/null +++ b/backend/api/internal/logic/client/common/wallet.go @@ -0,0 +1,476 @@ +package common + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "io" + "strings" + "time" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + base "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" + "golang.org/x/crypto/hkdf" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type walletOwner struct { + Type, Identity, Phone string + ID uint64 +} + +func currentOwner(ctx *gin.Context, client string) (walletOwner, bool) { + if client == "user_app" { + account, ok := UserAccount(ctx) + return walletOwner{Type: "user", Identity: account.Identity, Phone: account.Phone, ID: account.ID}, ok + } + account, ok := StaffAccount(ctx) + return walletOwner{Type: "staff", Identity: account.Identity, Phone: account.Phone, ID: account.ID}, ok +} + +func ensureWallet(tx *gorm.DB, owner walletOwner) (models.WalletBasic, error) { + var wallet models.WalletBasic + err := tx.Where("owner_type = ? AND owner_identity = ?", owner.Type, owner.Identity).First(&wallet).Error + if err == nil { + return wallet, nil + } + if err != gorm.ErrRecordNotFound { + return wallet, err + } + wallet = models.WalletBasic{ + Entity: base.NewEntity(base.StatusEnable), OwnerType: owner.Type, OwnerID: owner.ID, OwnerIdentity: owner.Identity, + } + if err := tx.Create(&wallet).Error; err != nil { + return wallet, err + } + return wallet, nil +} + +// GetWallet 延迟创建并返回当前主体钱包。 +func GetWallet(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + wallet, err := ensureWallet(impl.DBService, owner) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{ + "identity": wallet.Identity, "balance": wallet.Balance, + "withdrawal_balance": wallet.WithdrawalBalance, "payment_password_set": wallet.PayPasswordHash != "", + }) + } +} + +// SetPaymentPassword 设置或重置六位数字支付密码。 +func SetPaymentPassword(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + var request struct { + NewPassword string `json:"new_password" binding:"required,len=6,numeric"` + CurrentPassword string `json:"current_password"` + Code string `json:"code"` + RequestIdentity string `json:"request_identity"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + wallet, err := ensureWallet(impl.DBService, owner) + if err != nil { + infra.Response.Error(ctx, err) + return + } + valid := wallet.PayPasswordHash == "" && VerifyCode(client, owner.Phone, "set_payment_password", request.RequestIdentity, request.Code) + if wallet.PayPasswordHash != "" { + valid = bcrypt.CompareHashAndPassword([]byte(wallet.PayPasswordHash), []byte(request.CurrentPassword)) == nil || + VerifyCode(client, owner.Phone, "reset_payment_password", request.RequestIdentity, request.Code) + } + if !valid { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + hash, _ := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost) + if err := impl.DBService.Model(&wallet).Update("pay_password_hash", string(hash)).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"changed": true}) + } +} + +// CreateRecharge 创建待支付充值订单,不直接增加余额。 +func CreateRecharge(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + var request struct { + Amount int64 `json:"amount" binding:"required,gt=0"` + Channel string `json:"channel" binding:"required,oneof=mock wechat alipay"` + RequestNo string `json:"request_no" binding:"required"` + } + if ctx.ShouldBindJSON(&request) != nil || request.Amount > config.Spec.Global.ManualRechargeMaxAmount || + (request.Channel == "mock" && !config.Spec.Global.MockPaymentEnabled) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + wallet, err := ensureWallet(impl.DBService, owner) + if err != nil { + infra.Response.Error(ctx, err) + return + } + order := models.WalletRechargeOrder{ + Entity: base.NewEntity(base.StatusEnable), RechargeStatus: 10, WalletBasicID: wallet.ID, + RechargeNo: RecordNo("RC"), RequestNo: request.RequestNo, Amount: request.Amount, + Channel: request.Channel, OwnerType: owner.Type, OwnerIdentity: owner.Identity, + } + if err := impl.DBService.Create(&order).Error; err != nil { + var existing models.WalletRechargeOrder + if impl.DBService.Where("request_no = ? AND owner_identity = ?", request.RequestNo, owner.Identity).First(&existing).Error != nil { + infra.Response.Error(ctx, err) + return + } + order = existing + } + infra.Response.Success(ctx, base.ResourceResponse(order)) + } +} + +// ConfirmMockRecharge 模拟支付回调,生产关闭;事务内只入账一次。 +func ConfirmMockRecharge(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + if !config.Spec.Global.MockPaymentEnabled { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + return + } + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + var response models.WalletRechargeOrder + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("identity = ? AND owner_identity = ?", ctx.Param("identity"), owner.Identity).First(&response).Error; err != nil { + return err + } + if response.RechargeStatus == 23 { + return nil + } + if response.RechargeStatus != 10 || response.Channel != "mock" { + return gorm.ErrInvalidData + } + var wallet models.WalletBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&wallet, response.WalletBasicID).Error; err != nil { + return err + } + wallet.Balance += response.Amount + if err := tx.Model(&wallet).Update("balance", wallet.Balance).Error; err != nil { + return err + } + now := time.Now() + if err := tx.Model(&response).Updates(map[string]any{"recharge_status": 23, "completed_at": &now}).Error; err != nil { + return err + } + date := now.In(time.Local) + return tx.Create(&models.WalletRecord{ + Entity: base.NewEntity(base.StatusEnable), WalletBasicID: wallet.ID, RecordNo: RecordNo("WR"), + RequestNo: "recharge:" + response.Identity, Direction: "income", TradeType: "recharge", + Amount: response.Amount, BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance, + InTradeNo: response.RechargeNo, PayChannel: "mock", OperatorIdentity: owner.Identity, + Ymd: int32(date.Year()*10000 + int(date.Month())*100 + date.Day()), Ym: int32(date.Year()*100 + int(date.Month())), + }).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"confirmed": true}) + } +} + +// ListWalletRecords 返回当前钱包不可变流水。 +func ListWalletRecords(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + wallet, err := ensureWallet(impl.DBService, owner) + if err != nil { + infra.Response.Error(ctx, err) + return + } + var list []models.WalletRecord + if err := impl.DBService.Where("wallet_basic_id = ?", wallet.ID).Order("created_at desc").Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, base.ResourceResponse(list)) + } +} + +// ListBanks 仅返回银行卡掩码和非敏感字段。 +func ListBanks(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + wallet, err := ensureWallet(impl.DBService, owner) + if err != nil { + infra.Response.Error(ctx, err) + return + } + var banks []models.WalletBank + if err := impl.DBService.Where("wallet_basic_id = ? AND status <> ?", wallet.ID, base.StatusArchived).Find(&banks).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + list := make([]gin.H, 0, len(banks)) + for _, bank := range banks { + list = append(list, gin.H{"identity": bank.Identity, "card_no_masked": "**** **** **** " + bank.CardNoLast4, "bank_name": bank.BankName, "card_owner": bank.CardOwner, "bank_type": bank.BankType}) + } + infra.Response.Success(ctx, list) + } +} + +// BindBank 加密保存银行卡;支付渠道绑定标识在首期保持为空。 +func BindBank(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + var request struct { + CardNo string `json:"card_no" binding:"required,min=12,max=32"` + BankName string `json:"bank_name" binding:"required,max=128"` + CardOwner string `json:"card_owner" binding:"required,max=128"` + IDCard string `json:"id_card" binding:"required"` + Phone string `json:"phone" binding:"required"` + BankType string `json:"bank_type"` + Bank string `json:"bank"` + PaymentPassword string `json:"payment_password"` + Code string `json:"code"` + RequestIdentity string `json:"request_identity"` + } + if ctx.ShouldBindJSON(&request) != nil || !ValidPhone(request.Phone) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + wallet, err := ensureWallet(impl.DBService, owner) + if err != nil { + infra.Response.Error(ctx, err) + return + } + if !walletProof(wallet, client, owner.Phone, "bind_bank", request.PaymentPassword, request.RequestIdentity, request.Code) { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + cardCipher, fingerprint, err := protectField(request.CardNo) + if err != nil { + infra.Response.Error(ctx, err) + return + } + idCipher, _, _ := protectField(request.IDCard) + phoneCipher, _, _ := protectField(request.Phone) + bank := models.WalletBank{ + Entity: base.NewEntity(base.StatusEnable), WalletBasicID: wallet.ID, CardNoCiphertext: cardCipher, + CardFingerprint: fingerprint, CardNoLast4: request.CardNo[len(request.CardNo)-4:], + BankName: request.BankName, CardOwner: request.CardOwner, IDCardCiphertext: idCipher, + PhoneCiphertext: phoneCipher, BankType: request.BankType, Bank: request.Bank, + } + if err := impl.DBService.Create(&bank).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"identity": bank.Identity, "card_no_masked": "**** **** **** " + bank.CardNoLast4}) + } +} + +// UnbindBank 归档银行卡;存在待处理提现时拒绝。 +func UnbindBank(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + var request struct { + PaymentPassword string `json:"payment_password"` + Code string `json:"code"` + RequestIdentity string `json:"request_identity"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + wallet, err := ensureWallet(impl.DBService, owner) + if err != nil || !walletProof(wallet, client, owner.Phone, "unbind_bank", request.PaymentPassword, request.RequestIdentity, request.Code) { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + var bank models.WalletBank + if impl.DBService.Where("identity = ? AND wallet_basic_id = ? AND status <> ?", ctx.Param("identity"), wallet.ID, base.StatusArchived).First(&bank).Error != nil { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + var count int64 + impl.DBService.Model(&models.WalletApplyCash{}).Where("wallet_bank_id = ? AND apply_status IN ?", bank.ID, []int{10, 18}).Count(&count) + if count > 0 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + if err := impl.DBService.Model(&bank).Update("status", base.StatusArchived).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"archived": true}) + } +} + +// CreateWithdrawal 创建提现申请并预扣可提现余额。 +func CreateWithdrawal(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + var request struct { + BankIdentity string `json:"bank_identity" binding:"required"` + Amount int64 `json:"amount" binding:"required,gt=0"` + RequestNo string `json:"request_no" binding:"required"` + PaymentPassword string `json:"payment_password" binding:"required"` + Remark string `json:"remark"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + wallet, err := ensureWallet(impl.DBService, owner) + if err != nil || !VerifyPaymentPassword(owner.Identity, wallet, request.PaymentPassword) { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + var apply models.WalletApplyCash + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&wallet, wallet.ID).Error; err != nil { + return err + } + if wallet.WithdrawalBalance < request.Amount { + return gorm.ErrInvalidData + } + var bank models.WalletBank + if err := tx.Where("identity = ? AND wallet_basic_id = ? AND status = ?", request.BankIdentity, wallet.ID, base.StatusEnable).First(&bank).Error; err != nil { + return err + } + apply = models.WalletApplyCash{ + Entity: base.NewEntity(base.StatusEnable), ApplyStatus: 10, WalletBasicID: wallet.ID, + WalletBankID: bank.ID, CashNo: RecordNo("WD"), RequestNo: request.RequestNo, + Amount: request.Amount, Channel: "bank", Remark: request.Remark, + } + if err := tx.Create(&apply).Error; err != nil { + return err + } + return tx.Model(&wallet).Update("withdrawal_balance", gorm.Expr("withdrawal_balance - ?", request.Amount)).Error + }) + if err != nil { + var existing models.WalletApplyCash + if impl.DBService.Where("request_no = ? AND wallet_basic_id = ?", request.RequestNo, wallet.ID).First(&existing).Error != nil { + infra.Response.Error(ctx, err) + return + } + apply = existing + } + infra.Response.Success(ctx, base.ResourceResponse(apply)) + } +} + +// ListWithdrawals 返回当前钱包提现申请。 +func ListWithdrawals(client string) gin.HandlerFunc { + return func(ctx *gin.Context) { + owner, ok := currentOwner(ctx, client) + if !ok { + return + } + wallet, err := ensureWallet(impl.DBService, owner) + if err != nil { + infra.Response.Error(ctx, err) + return + } + var list []models.WalletApplyCash + if err := impl.DBService.Where("wallet_basic_id = ?", wallet.ID).Order("created_at desc").Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, base.ResourceResponse(list)) + } +} + +func walletProof(wallet models.WalletBasic, client, phone, purpose, password, requestIdentity, code string) bool { + return wallet.PayPasswordHash != "" && bcrypt.CompareHashAndPassword([]byte(wallet.PayPasswordHash), []byte(password)) == nil || + VerifyCode(client, phone, purpose, requestIdentity, code) +} + +// VerifyPaymentPassword 校验支付密码,并在 Redis 中累计失败次数、短时锁定。 +func VerifyPaymentPassword(ownerIdentity string, wallet models.WalletBasic, password string) bool { + lockKey := impl.RedisService.BuildKey("payment-password-lock", ownerIdentity) + var locked bool + if impl.RedisService.Get(lockKey, &locked) == nil && locked { + return false + } + if wallet.PayPasswordHash != "" && bcrypt.CompareHashAndPassword([]byte(wallet.PayPasswordHash), []byte(password)) == nil { + _ = impl.RedisService.Delete(impl.RedisService.BuildKey("payment-password-failures", ownerIdentity)) + return true + } + failureKey := impl.RedisService.BuildKey("payment-password-failures", ownerIdentity) + var failures int + _ = impl.RedisService.Get(failureKey, &failures) + failures++ + _ = impl.RedisService.Set(failureKey, failures, 15*time.Minute) + if failures >= 5 { + _ = impl.RedisService.Set(lockKey, true, 15*time.Minute) + } + return false +} + +func protectField(value string) (string, string, error) { + reader := hkdf.New(sha256.New, []byte(config.Spec.Global.FieldEncryptionKey), nil, []byte("heqi-wallet-field-v1")) + key := make([]byte, 64) + if _, err := io.ReadFull(reader, key); err != nil { + return "", "", err + } + block, err := aes.NewCipher(key[:32]) + if err != nil { + return "", "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", "", err + } + sealed := gcm.Seal(nil, nonce, []byte(strings.TrimSpace(value)), nil) + mac := hmac.New(sha256.New, key[32:]) + _, _ = mac.Write([]byte(strings.TrimSpace(value))) + return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), hex.EncodeToString(mac.Sum(nil)), nil +} diff --git a/backend/api/internal/logic/client/staff/auth.go b/backend/api/internal/logic/client/staff/auth.go new file mode 100644 index 0000000..7d58efa --- /dev/null +++ b/backend/api/internal/logic/client/staff/auth.go @@ -0,0 +1,123 @@ +// Package staff 实现工作人员 App 的服务端业务接口。 +package staff + +import ( + "strings" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common" + base "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" +) + +var supportedRoles = map[string]bool{"delivery": true, "installer": true, "operations": true} + +// Login 登录启用且角色受支持、资质有效的工作人员账户。 +func Login(ctx *gin.Context) { + var request struct { + Phone string `json:"phone" binding:"required"` + Mode string `json:"mode" binding:"required,oneof=password verification_code"` + Password string `json:"password"` + Code string `json:"code"` + RequestIdentity string `json:"request_identity"` + } + if ctx.ShouldBindJSON(&request) != nil || !clientcommon.ValidPhone(request.Phone) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var account models.StaffAccount + if impl.DBService.Where("phone = ? AND status = ?", strings.TrimSpace(request.Phone), base.StatusEnable).First(&account).Error != nil || + !supportedRoles[account.RoleCode] { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + valid := request.Mode == "password" && bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.Password)) == nil + if request.Mode == "verification_code" { + valid = clientcommon.VerifyCode("service_app", account.Phone, "login", request.RequestIdentity, request.Code) + } + if !valid { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + accessToken, err := clientcommon.IssueToken(account.Identity, "service_app", account.RoleCode, map[string]string{"phone": account.Phone}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"access_token": accessToken, "token_type": "JWT", "identity": account.Identity, "role_code": account.RoleCode}) +} + +// Profile 返回工作人员岗位和归属。 +func Profile(ctx *gin.Context) { + account, ok := clientcommon.StaffAccount(ctx) + if !ok { + return + } + infra.Response.Success(ctx, gin.H{ + "identity": account.Identity, "name": account.Name, "phone": account.Phone, "avatar": account.Avatar, + "role_code": account.RoleCode, "work_status": account.WorkStatus, + }) +} + +// ChangePassword 修改当前工作人员登录密码。 +func ChangePassword(ctx *gin.Context) { + account, ok := clientcommon.StaffAccount(ctx) + if !ok { + return + } + var request struct { + CurrentPassword string `json:"current_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` + } + if ctx.ShouldBindJSON(&request) != nil || !base.IsValidAccountPassword(request.NewPassword) || + bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.CurrentPassword)) != nil { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + hash, err := base.PasswordHash(request.NewPassword) + if err != nil { + infra.Response.Error(ctx, err) + return + } + if err := impl.DBService.Model(&account).Update("password_hash", hash).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"changed": true}) +} + +// ResetPassword 使用验证码重置工作人员登录密码,不开放注册。 +func ResetPassword(ctx *gin.Context) { + var request struct { + Phone string `json:"phone" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` + Code string `json:"code" binding:"required"` + RequestIdentity string `json:"request_identity" binding:"required"` + } + if ctx.ShouldBindJSON(&request) != nil || !base.IsValidAccountPassword(request.NewPassword) || + !clientcommon.VerifyCode("service_app", request.Phone, "reset_login_password", request.RequestIdentity, request.Code) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + hash, err := base.PasswordHash(request.NewPassword) + if err != nil { + infra.Response.Error(ctx, err) + return + } + result := impl.DBService.Model(&models.StaffAccount{}). + Where("phone = ? AND status = ?", strings.TrimSpace(request.Phone), base.StatusEnable). + Update("password_hash", hash) + if result.Error != nil { + infra.Response.Error(ctx, result.Error) + return + } + if result.RowsAffected != 1 { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + infra.Response.Success(ctx, gin.H{"changed": true}) +} diff --git a/backend/api/internal/logic/client/staff/work.go b/backend/api/internal/logic/client/staff/work.go new file mode 100644 index 0000000..826a88e --- /dev/null +++ b/backend/api/internal/logic/client/staff/work.go @@ -0,0 +1,226 @@ +package staff + +import ( + "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" + clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common" + base "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" +) + +// Attendance 上下班打卡;存在进行中任务时禁止下班。 +func Attendance(ctx *gin.Context) { + account, ok := clientcommon.StaffAccount(ctx) + if !ok { + return + } + var request struct { + Action string `json:"action" binding:"required,oneof=clock_in clock_out"` + OccurredAt time.Time `json:"occurred_at" binding:"required"` + Longitude string `json:"longitude" binding:"required"` + Latitude string `json:"latitude" binding:"required"` + DeviceIdentity string `json:"device_identity" binding:"required"` + RequestNo string `json:"request_no" binding:"required"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + if request.Action == "clock_out" { + var count int64 + impl.DBService.Model(&models.GasorderBasic{}).Where("staff_account_id = ? AND order_status IN ?", account.ID, []int{18, 11, 21, 34}).Count(&count) + if count == 0 { + impl.DBService.Model(&models.CsTicket{}).Where("staff_account_id = ? AND ticket_status IN ?", account.ID, []int{18, 11, 21, 34}).Count(&count) + } + if count > 0 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + } + record := models.StaffAttendance{ + Entity: base.NewEntity(base.StatusEnable), StaffAccountID: account.ID, RoleCode: account.RoleCode, + Action: request.Action, OccurredAt: request.OccurredAt, Longitude: request.Longitude, + Latitude: request.Latitude, DeviceIdentity: request.DeviceIdentity, RequestNo: request.RequestNo, + } + workStatus := "on_duty" + if request.Action == "clock_out" { + workStatus = "off_duty" + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&record).Error; err != nil { + return err + } + return tx.Model(&account).Update("work_status", workStatus).Error + }) + if err != nil { + var existing models.StaffAttendance + if impl.DBService.Where("request_no = ? AND staff_account_id = ?", request.RequestNo, account.ID).First(&existing).Error != nil { + infra.Response.Error(ctx, err) + return + } + record = existing + } + infra.Response.Success(ctx, gin.H{"identity": record.Identity, "work_status": workStatus}) +} + +// ListTickets 仅返回分派给当前人员且与岗位匹配的工单。 +func ListTickets(ctx *gin.Context) { + account, ok := clientcommon.StaffAccount(ctx) + if !ok { + return + } + categories := []string{"installation", "repair"} + if account.RoleCode == "operations" { + categories = []string{"inspection", "reinspection"} + } + if account.RoleCode == "delivery" { + infra.Response.Success(ctx, []models.CsTicket{}) + return + } + var list []models.CsTicket + if err := impl.DBService.Where("staff_account_id = ? AND category IN ? AND status <> ?", account.ID, categories, base.StatusArchived). + Order("created_at desc").Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, base.ResourceResponse(list)) +} + +// StartTicket 将本人已分派工单置为处理中。 +func StartTicket(ctx *gin.Context) { + updateTicketStatus(ctx, 18, 11, nil) +} + +// ExceptionTicket 标记本人处理中工单异常。 +func ExceptionTicket(ctx *gin.Context) { + updateTicketStatus(ctx, 11, 21, nil) +} + +// RecoverTicket 恢复本人异常工单。 +func RecoverTicket(ctx *gin.Context) { + updateTicketStatus(ctx, 21, 11, nil) +} + +// SubmitTicketResult 追加现场证据并提交用户确认;不合格或高风险结果必须进入异常。 +func SubmitTicketResult(ctx *gin.Context) { + account, ok := clientcommon.StaffAccount(ctx) + if !ok { + return + } + var request struct { + Result string `json:"result" binding:"required,max=2000"` + Conclusion string `json:"conclusion" binding:"required,oneof=qualified noncompliant high_risk"` + Evidences []struct { + EvidenceType string `json:"evidence_type" binding:"required"` + MediaType string `json:"media_type" binding:"required,oneof=image video signature"` + FileURI string `json:"file_uri" binding:"required"` + CapturedAt time.Time `json:"captured_at" binding:"required"` + Longitude string `json:"longitude" binding:"required"` + Latitude string `json:"latitude" binding:"required"` + RequestNo string `json:"request_no" binding:"required"` + } `json:"evidences" binding:"required,min=2"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var ticket models.CsTicket + if impl.DBService.Where("identity = ? AND staff_account_id = ? AND ticket_status = ?", ctx.Param("identity"), account.ID, 11).First(&ticket).Error != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + images, videos, signatures := 0, 0, 0 + stages := map[string]bool{} + for _, evidence := range request.Evidences { + if !strings.HasPrefix(evidence.FileURI, "/uploads/") { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + switch evidence.MediaType { + case "image": + images++ + case "video": + videos++ + case "signature": + signatures++ + } + stages[evidence.EvidenceType] = true + } + valid := images <= 6 && videos <= 3 && signatures >= 1 + if ticket.Category == "installation" || ticket.Category == "repair" { + valid = valid && stages["before"] && stages["during"] && stages["after"] + } else { + valid = valid && images >= 1 + } + if !valid { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + nextStatus := 34 + if request.Conclusion != "qualified" { + nextStatus = 21 + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + now := time.Now() + for _, item := range request.Evidences { + record := models.CsTicketEvidence{ + Entity: base.NewEntity(base.StatusEnable), CsTicketID: ticket.ID, EvidenceType: item.EvidenceType, + MediaType: item.MediaType, FileURI: item.FileURI, CapturedAt: item.CapturedAt, ReceivedAt: now, + Longitude: item.Longitude, Latitude: item.Latitude, Source: "app", + IntegrityStatus: "unverified", OperatorIdentity: account.Identity, RequestNo: item.RequestNo, + } + if err := tx.Create(&record).Error; err != nil { + return err + } + } + result := tx.Model(&models.CsTicket{}).Where("id = ? AND ticket_status = ?", ticket.ID, 11). + Updates(map[string]any{"ticket_status": nextStatus, "result": request.Result, "operator_identity": account.Identity}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrInvalidData + } + return nil + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"ticket_status": nextStatus}) +} + +func updateTicketStatus(ctx *gin.Context, from, to int, extra map[string]any) { + account, ok := clientcommon.StaffAccount(ctx) + if !ok { + return + } + values := map[string]any{"ticket_status": to, "operator_identity": account.Identity} + if extra != nil { + for key, value := range extra { + values[key] = value + } + } + if to == 11 { + now := time.Now() + values["started_at"] = &now + } + result := impl.DBService.Model(&models.CsTicket{}). + Where("identity = ? AND staff_account_id = ? AND ticket_status = ?", ctx.Param("identity"), account.ID, from). + Updates(values) + if result.Error != nil { + infra.Response.Error(ctx, result.Error) + return + } + if result.RowsAffected != 1 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"ticket_status": to}) +} diff --git a/backend/api/internal/logic/client/user/address_ticket.go b/backend/api/internal/logic/client/user/address_ticket.go new file mode 100644 index 0000000..d8e09b8 --- /dev/null +++ b/backend/api/internal/logic/client/user/address_ticket.go @@ -0,0 +1,165 @@ +package user + +import ( + "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" + clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common" + base "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" +) + +// ListAddresses 返回当前用户未归档地址。 +func ListAddresses(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + var list []models.UserAddress + if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, base.StatusArchived).Order("is_default desc, created_at desc").Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, base.ResourceResponse(list)) +} + +// SaveAddress 新增地址,设为默认时原默认地址会在同一事务取消默认。 +func SaveAddress(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + var request struct { + Address string `json:"address" binding:"required,max=255"` + Longitude string `json:"longitude"` + Latitude string `json:"latitude"` + IsDefault bool `json:"is_default"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + address := models.UserAddress{ + Entity: base.NewEntity(base.StatusEnable), UserAccountID: account.ID, Address: request.Address, + Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: request.IsDefault, + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if request.IsDefault { + if err := tx.Model(&models.UserAddress{}).Where("user_account_id = ?", account.ID).Update("is_default", false).Error; err != nil { + return err + } + } + return tx.Create(&address).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"identity": address.Identity}) +} + +var userTicketCategories = map[string]bool{ + "installation": true, "repair": true, "inspection": true, "reinspection": true, "customer_service": true, +} + +// CreateTicket 创建工单,服务人员只能由后台分派。 +func CreateTicket(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + var request struct { + RequestNo string `json:"request_no" binding:"required"` + Category string `json:"category" binding:"required"` + Description string `json:"description" binding:"required,max=2000"` + AddressIdentity string `json:"address_identity"` + AppointmentAt *time.Time `json:"appointment_at"` + } + if ctx.ShouldBindJSON(&request) != nil || !userTicketCategories[request.Category] { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var relation models.UserServiceRelation + _ = impl.DBService.Where("user_account_id = ? AND status = ?", account.ID, base.StatusEnable).First(&relation).Error + addressText := "" + if request.AddressIdentity != "" { + var address models.UserAddress + if impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", request.AddressIdentity, account.ID, base.StatusArchived).First(&address).Error != nil { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + addressText = address.Address + } + ticket := models.CsTicket{ + Entity: base.NewEntity(base.StatusEnable), TicketStatus: 32, TicketNo: clientcommon.RecordNo("TK"), + RequestNo: request.RequestNo, + UserAccountID: account.ID, GasBasicID: relation.GasBasicID, DeliveryBasicID: relation.DeliveryBasicID, + Category: request.Category, Priority: "normal", Description: strings.TrimSpace(request.Description), + Address: addressText, AppointmentAt: request.AppointmentAt, OperatorIdentity: account.Identity, + } + if err := impl.DBService.Create(&ticket).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"identity": ticket.Identity, "ticket_no": ticket.TicketNo, "ticket_status": ticket.TicketStatus}) +} + +// ListTickets 仅返回当前用户自己的工单。 +func ListTickets(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + var list []models.CsTicket + if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, base.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, base.ResourceResponse(list)) +} + +// ConfirmTicket 用户确认工作人员提交的处理结果。 +func ConfirmTicket(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + now := time.Now() + result := impl.DBService.Model(&models.CsTicket{}). + Where("identity = ? AND user_account_id = ? AND ticket_status = ?", ctx.Param("identity"), account.ID, 34). + Updates(map[string]any{"ticket_status": 23, "completed_at": &now, "operator_identity": account.Identity}) + if result.Error != nil { + infra.Response.Error(ctx, result.Error) + return + } + if result.RowsAffected != 1 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"confirmed": true}) +} + +// CancelTicket 取消尚未完成的本人工单。 +func CancelTicket(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + result := impl.DBService.Model(&models.CsTicket{}). + Where("identity = ? AND user_account_id = ? AND ticket_status IN ?", ctx.Param("identity"), account.ID, []int{32, 18, 11, 21, 34}). + Updates(map[string]any{"ticket_status": 22, "operator_identity": account.Identity}) + if result.Error != nil { + infra.Response.Error(ctx, result.Error) + return + } + if result.RowsAffected != 1 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"cancelled": true}) +} diff --git a/backend/api/internal/logic/client/user/auth.go b/backend/api/internal/logic/client/user/auth.go new file mode 100644 index 0000000..d11527f --- /dev/null +++ b/backend/api/internal/logic/client/user/auth.go @@ -0,0 +1,211 @@ +// Package user 实现用户 App 的服务端业务接口。 +package user + +import ( + "strings" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common" + base "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" + "gorm.io/gorm" +) + +type loginRequest struct { + Phone string `json:"phone" binding:"required"` + Mode string `json:"mode" binding:"required,oneof=password verification_code"` + Password string `json:"password"` + Code string `json:"code"` + RequestIdentity string `json:"request_identity"` +} + +// Login 支持密码和一次性验证码两种登录模式。 +func Login(ctx *gin.Context) { + var request loginRequest + if ctx.ShouldBindJSON(&request) != nil || !clientcommon.ValidPhone(request.Phone) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var account models.UserAccount + if impl.DBService.Where("phone = ? AND status = ?", strings.TrimSpace(request.Phone), base.StatusEnable).First(&account).Error != nil { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + valid := request.Mode == "password" && bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.Password)) == nil + if request.Mode == "verification_code" { + valid = clientcommon.VerifyCode("user_app", account.Phone, "login", request.RequestIdentity, request.Code) + } + if !valid { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + accessToken, err := clientcommon.IssueToken(account.Identity, "user_app", "user", map[string]string{"phone": account.Phone}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"access_token": accessToken, "token_type": "JWT", "identity": account.Identity}) +} + +// Register 创建普通或邀请用户;邀请归属和默认地址在同一事务内完成。 +func Register(ctx *gin.Context) { + var request struct { + Phone string `json:"phone" binding:"required"` + Password string `json:"password" binding:"required"` + Name string `json:"name" binding:"required,max=64"` + Address string `json:"address" binding:"required,max=255"` + Longitude string `json:"longitude"` + Latitude string `json:"latitude"` + GasIdentity string `json:"gas_identity"` + DeliveryIdentity string `json:"delivery_identity"` + Code string `json:"code" binding:"required"` + RequestIdentity string `json:"request_identity" binding:"required"` + } + if ctx.ShouldBindJSON(&request) != nil || !clientcommon.ValidPhone(request.Phone) || + !base.IsValidAccountPassword(request.Password) || + !clientcommon.VerifyCode("user_app", request.Phone, "register", request.RequestIdentity, request.Code) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + hash, err := base.PasswordHash(request.Password) + if err != nil { + infra.Response.Error(ctx, err) + return + } + account := models.UserAccount{ + Entity: base.NewEntity(base.StatusEnable), Username: strings.TrimSpace(request.Phone), + Phone: strings.TrimSpace(request.Phone), PasswordHash: hash, Name: strings.TrimSpace(request.Name), + } + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&account).Error; err != nil { + return err + } + address := models.UserAddress{ + Entity: base.NewEntity(base.StatusEnable), UserAccountID: account.ID, Address: request.Address, + Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: true, + } + if err := tx.Create(&address).Error; err != nil { + return err + } + if request.GasIdentity == "" { + if request.DeliveryIdentity != "" { + return gorm.ErrInvalidData + } + return nil + } + var gas models.GasBasic + if err := tx.Where("identity = ? AND status = ?", request.GasIdentity, base.StatusEnable).First(&gas).Error; err != nil { + return err + } + var deliveryID uint64 + if request.DeliveryIdentity != "" { + var delivery models.DeliveryBasic + if err := tx.Where("identity = ? AND gas_basic_id = ? AND status = ?", request.DeliveryIdentity, gas.ID, base.StatusEnable).First(&delivery).Error; err != nil { + return err + } + deliveryID = delivery.ID + } + return tx.Create(&models.UserServiceRelation{ + Entity: base.NewEntity(base.StatusEnable), UserAccountID: account.ID, + GasBasicID: gas.ID, DeliveryBasicID: deliveryID, + }).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"identity": account.Identity}) +} + +// Profile 返回当前用户的脱敏资料。 +func Profile(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + infra.Response.Success(ctx, gin.H{"identity": account.Identity, "name": account.Name, "phone": account.Phone, "avatar": account.Avatar, "real_name": account.RealName}) +} + +// UpdateProfile 只允许修改非认证资料。 +func UpdateProfile(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + var request struct { + Name string `json:"name" binding:"required,max=64"` + Avatar string `json:"avatar" binding:"max=512"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + if err := impl.DBService.Model(&account).Updates(map[string]any{"name": strings.TrimSpace(request.Name), "avatar": request.Avatar}).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"updated": true}) +} + +// ChangePassword 使用当前密码修改登录密码。 +func ChangePassword(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + var request struct { + CurrentPassword string `json:"current_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` + } + if ctx.ShouldBindJSON(&request) != nil || !base.IsValidAccountPassword(request.NewPassword) || + bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.CurrentPassword)) != nil { + infra.Response.Error(ctx, errcode.ErrPassword) + return + } + hash, err := base.PasswordHash(request.NewPassword) + if err != nil { + infra.Response.Error(ctx, err) + return + } + if err := impl.DBService.Model(&account).Update("password_hash", hash).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"changed": true}) +} + +// ResetPassword 使用限定用途的手机号验证码重置登录密码。 +func ResetPassword(ctx *gin.Context) { + var request struct { + Phone string `json:"phone" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` + Code string `json:"code" binding:"required"` + RequestIdentity string `json:"request_identity" binding:"required"` + } + if ctx.ShouldBindJSON(&request) != nil || !base.IsValidAccountPassword(request.NewPassword) || + !clientcommon.VerifyCode("user_app", request.Phone, "reset_login_password", request.RequestIdentity, request.Code) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + hash, err := base.PasswordHash(request.NewPassword) + if err != nil { + infra.Response.Error(ctx, err) + return + } + result := impl.DBService.Model(&models.UserAccount{}). + Where("phone = ? AND status = ?", strings.TrimSpace(request.Phone), base.StatusEnable). + Update("password_hash", hash) + if result.Error != nil { + infra.Response.Error(ctx, result.Error) + return + } + if result.RowsAffected != 1 { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + infra.Response.Success(ctx, gin.H{"changed": true}) +} diff --git a/backend/api/internal/logic/client/user/basic.go b/backend/api/internal/logic/client/user/basic.go new file mode 100644 index 0000000..b0d6506 --- /dev/null +++ b/backend/api/internal/logic/client/user/basic.go @@ -0,0 +1,92 @@ +package user + +import ( + "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" + clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common" + base "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +// PublicGasStations 提供注册页所需的最小启用气站数据。 +func PublicGasStations(ctx *gin.Context) { + var list []models.GasBasic + if err := impl.DBService.Select("identity", "name", "address").Where("status = ?", base.StatusEnable).Order("name").Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, base.ResourceResponse(list)) +} + +// PublicDeliveryPoints 提供指定气站下的启用配送点。 +func PublicDeliveryPoints(ctx *gin.Context) { + var gas models.GasBasic + if impl.DBService.Where("identity = ? AND status = ?", ctx.Query("gas_identity"), base.StatusEnable).First(&gas).Error != nil { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + var list []models.DeliveryBasic + if err := impl.DBService.Select("identity", "name", "address").Where("gas_basic_id = ? AND status = ?", gas.ID, base.StatusEnable).Order("name").Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, base.ResourceResponse(list)) +} + +// PublicContents 返回已发布内容,支持内容类型筛选。 +func PublicContents(ctx *gin.Context) { + query := impl.DBService.Where("status = ? AND publish_status = ?", base.StatusEnable, "published") + if contentType := strings.TrimSpace(ctx.Query("content_type")); contentType != "" { + query = query.Where("content_type = ?", contentType) + } + var list []models.CmsContent + if err := query.Order("created_at desc").Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, base.ResourceResponse(list)) +} + +// ConfirmContentRead 记录用户对特定内容版本的确认,幂等号全局唯一。 +func ConfirmContentRead(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + var request struct { + ContentIdentity string `json:"content_identity" binding:"required"` + ClientVersion string `json:"client_version"` + DeviceIdentity string `json:"device_identity"` + RequestNo string `json:"request_no" binding:"required"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var content models.CmsContent + if impl.DBService.Where("identity = ? AND publish_status = ?", request.ContentIdentity, "published").First(&content).Error != nil { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + record := models.CmsContentRead{ + Entity: base.NewEntity(base.StatusEnable), UserAccountID: account.ID, CmsContentID: content.ID, + VersionNo: content.VersionNo, ShownAt: time.Now(), ConfirmedAt: timePointer(time.Now()), + ClientVersion: request.ClientVersion, DeviceIdentity: request.DeviceIdentity, RequestNo: request.RequestNo, + } + if err := impl.DBService.Create(&record).Error; err != nil { + var existing models.CmsContentRead + if impl.DBService.Where("request_no = ? AND user_account_id = ?", request.RequestNo, account.ID).First(&existing).Error != nil { + infra.Response.Error(ctx, err) + return + } + record = existing + } + infra.Response.Success(ctx, gin.H{"identity": record.Identity, "content_version": record.VersionNo}) +} + +func timePointer(value time.Time) *time.Time { return &value } diff --git a/backend/api/internal/logic/client/user/gasorder.go b/backend/api/internal/logic/client/user/gasorder.go new file mode 100644 index 0000000..413ca16 --- /dev/null +++ b/backend/api/internal/logic/client/user/gasorder.go @@ -0,0 +1,86 @@ +package user + +import ( + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common" + base "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +// ServiceRelation 返回当前唯一有效服务归属的公开 identity。 +func ServiceRelation(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + var relation models.UserServiceRelation + if impl.DBService.Where("user_account_id = ? AND status = ?", account.ID, base.StatusEnable).First(&relation).Error != nil { + infra.Response.Success(ctx, nil) + return + } + response := gin.H{} + if relation.GasBasicID != 0 { + var gas models.GasBasic + if impl.DBService.First(&gas, relation.GasBasicID).Error == nil { + response["gas_identity"], response["gas_name"] = gas.Identity, gas.Name + } + } + if relation.DeliveryBasicID != 0 { + var delivery models.DeliveryBasic + if impl.DBService.First(&delivery, relation.DeliveryBasicID).Error == nil { + response["delivery_identity"], response["delivery_name"] = delivery.Identity, delivery.Name + } + } + infra.Response.Success(ctx, response) +} + +// ListGasContracts 返回用户自己的供气合同。 +func ListGasContracts(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + var list []models.GasorderContract + if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, base.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, base.ResourceResponse(list)) +} + +// ListGasOrders 返回用户自己的供气订单。 +func ListGasOrders(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + var list []models.GasorderBasic + if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, base.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, base.ResourceResponse(list)) +} + +// CancelGasOrder 仅允许取消已创建或已分派的本人订单。 +func CancelGasOrder(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + result := impl.DBService.Model(&models.GasorderBasic{}). + Where("identity = ? AND user_account_id = ? AND order_status IN ?", ctx.Param("identity"), account.ID, []int{base.StatusCreated, base.StatusAssigned}). + Updates(map[string]any{"order_status": base.StatusCancelled, "operator_identity": account.Identity}) + if result.Error != nil { + infra.Response.Error(ctx, result.Error) + return + } + if result.RowsAffected != 1 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"cancelled": true}) +} diff --git a/backend/api/internal/logic/client/user/shop.go b/backend/api/internal/logic/client/user/shop.go new file mode 100644 index 0000000..0c0264d --- /dev/null +++ b/backend/api/internal/logic/client/user/shop.go @@ -0,0 +1,219 @@ +package user + +import ( + "encoding/json" + "time" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common" + base "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" +) + +// PublicProducts 返回上架且有库存的商品。 +func PublicProducts(ctx *gin.Context) { + var list []models.EcProduct + if err := impl.DBService.Where("status = ? AND stock_quantity > 0", base.StatusEnable).Order("created_at desc").Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, base.ResourceResponse(list)) +} + +// CreateShopOrder 按服务端价格创建订单并原子扣减库存。 +func CreateShopOrder(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + var request struct { + RequestNo string `json:"request_no" binding:"required"` + AddressIdentity string `json:"address_identity" binding:"required"` + ContactName string `json:"contact_name" binding:"required"` + ContactPhone string `json:"contact_phone" binding:"required"` + Remark string `json:"remark"` + Items []struct { + ProductIdentity string `json:"product_identity" binding:"required"` + Quantity int `json:"quantity" binding:"required,gt=0"` + } `json:"items" binding:"required,min=1"` + } + if ctx.ShouldBindJSON(&request) != nil || !clientcommon.ValidPhone(request.ContactPhone) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var address models.UserAddress + if impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", request.AddressIdentity, account.ID, base.StatusArchived).First(&address).Error != nil { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + order := models.EcOrder{ + Entity: base.NewEntity(base.StatusEnable), OrderStatus: 16, OrderNo: clientcommon.RecordNo("EC"), + RequestNo: request.RequestNo, UserAccountID: account.ID, UserAddressID: address.ID, + Address: address.Address, Longitude: address.Longitude, Latitude: address.Latitude, + ContactName: request.ContactName, ContactPhone: request.ContactPhone, Remark: request.Remark, LogisticsStatus: 10, + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var amount int64 + items := make([]models.EcOrderItem, 0, len(request.Items)) + for _, requested := range request.Items { + var product models.EcProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("identity = ? AND status = ? AND stock_quantity >= ?", requested.ProductIdentity, base.StatusEnable, requested.Quantity). + First(&product).Error; err != nil { + return err + } + if err := tx.Model(&product).Update("stock_quantity", gorm.Expr("stock_quantity - ?", requested.Quantity)).Error; err != nil { + return err + } + snapshot, _ := json.Marshal(gin.H{"identity": product.Identity, "name": product.Name, "product_code": product.ProductCode}) + items = append(items, models.EcOrderItem{ + Entity: base.NewEntity(base.StatusEnable), EcProductID: product.ID, ProductSnapshot: string(snapshot), + Quantity: requested.Quantity, SaleAmount: product.PriceAmount, + }) + amount += product.PriceAmount * int64(requested.Quantity) + } + order.ProductAmount, order.TotalAmount, order.PayableAmount = amount, amount, amount + if err := tx.Create(&order).Error; err != nil { + return err + } + for i := range items { + items[i].EcOrderID = order.ID + if err := tx.Create(&items[i]).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + var existing models.EcOrder + if impl.DBService.Where("request_no = ? AND user_account_id = ?", request.RequestNo, account.ID).First(&existing).Error != nil { + infra.Response.Error(ctx, err) + return + } + order = existing + } + infra.Response.Success(ctx, base.ResourceResponse(order)) +} + +// ListShopOrders 返回本人的商城订单。 +func ListShopOrders(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + var list []models.EcOrder + if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, base.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, base.ResourceResponse(list)) +} + +// CancelShopOrder 取消未支付订单并恢复库存。 +func CancelShopOrder(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var order models.EcOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("identity = ? AND user_account_id = ? AND order_status = ?", ctx.Param("identity"), account.ID, 16).First(&order).Error; err != nil { + return err + } + var items []models.EcOrderItem + if err := tx.Where("ec_order_id = ?", order.ID).Find(&items).Error; err != nil { + return err + } + for _, item := range items { + if err := tx.Model(&models.EcProduct{}).Where("id = ?", item.EcProductID).Update("stock_quantity", gorm.Expr("stock_quantity + ?", item.Quantity)).Error; err != nil { + return err + } + } + return tx.Model(&order).Update("order_status", 22).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"cancelled": true}) +} + +// PayShopOrder 使用用户钱包余额支付,金额和订单状态由服务端锁定校验。 +func PayShopOrder(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + var request struct { + PaymentPassword string `json:"payment_password" binding:"required"` + RequestNo string `json:"request_no" binding:"required"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var order models.EcOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("identity = ? AND user_account_id = ? AND order_status = ?", ctx.Param("identity"), account.ID, 16).First(&order).Error; err != nil { + return err + } + var wallet models.WalletBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("owner_type = ? AND owner_identity = ?", "user", account.Identity).First(&wallet).Error; err != nil { + return err + } + if !clientcommon.VerifyPaymentPassword(account.Identity, wallet, request.PaymentPassword) || + wallet.Balance < order.PayableAmount { + return gorm.ErrInvalidData + } + wallet.Balance -= order.PayableAmount + if err := tx.Model(&wallet).Update("balance", wallet.Balance).Error; err != nil { + return err + } + now := time.Now() + if err := tx.Model(&order).Updates(map[string]any{"order_status": 18, "paid_at": &now}).Error; err != nil { + return err + } + date := now.In(time.Local) + return tx.Create(&models.WalletRecord{ + Entity: base.NewEntity(base.StatusEnable), WalletBasicID: wallet.ID, RecordNo: clientcommon.RecordNo("WR"), + RequestNo: request.RequestNo, Direction: "expense", TradeType: "ec_order", + Amount: order.PayableAmount, BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance, + OutTradeNo: order.OrderNo, PayChannel: "wallet", OperatorIdentity: account.Identity, + Ymd: int32(date.Year()*10000 + int(date.Month())*100 + date.Day()), Ym: int32(date.Year()*100 + int(date.Month())), + }).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"paid": true}) +} + +// ConfirmShopReceipt 只推进独立物流状态,不伪造支付状态。 +func ConfirmShopReceipt(ctx *gin.Context) { + account, ok := clientcommon.UserAccount(ctx) + if !ok { + return + } + now := time.Now() + result := impl.DBService.Model(&models.EcOrder{}). + Where("identity = ? AND user_account_id = ? AND logistics_status = ?", ctx.Param("identity"), account.ID, 20). + Updates(map[string]any{"logistics_status": 30, "received_at": &now}) + if result.Error != nil { + infra.Response.Error(ctx, result.Error) + return + } + if result.RowsAffected != 1 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"received": true}) +} diff --git a/backend/api/internal/logic/upload/upload.go b/backend/api/internal/logic/upload/upload.go index d713eb8..54fa726 100644 --- a/backend/api/internal/logic/upload/upload.go +++ b/backend/api/internal/logic/upload/upload.go @@ -3,6 +3,7 @@ package upload import ( "io" + "log" "net/http" "os" "path/filepath" @@ -11,14 +12,17 @@ import ( "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/config" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" ) const maxUploadSize int64 = 10 << 20 -var allowedExtensions = map[string]struct{}{ - ".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {}, ".pdf": {}, +var allowedExtensions = map[string]bool{ + ".jpg": false, ".jpeg": false, ".png": false, ".webp": false, ".pdf": false, + ".mp4": true, ".mov": true, } // UploadFileReply 是文件上传完成后返回的受控资源标识。 @@ -31,15 +35,17 @@ type UploadFileReply struct { // UploadFile 将允许类型的文件保存至本地 Mock 存储,不直接暴露绝对磁盘路径。 func UploadFile(ctx *gin.Context) { - ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxUploadSize) + ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, config.Spec.Global.UploadVideoMaxSize) fileHeader, err := ctx.FormFile("file") - if err != nil || fileHeader == nil || fileHeader.Size <= 0 || fileHeader.Size > maxUploadSize { + if err != nil || fileHeader == nil || fileHeader.Size <= 0 { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } extension := strings.ToLower(filepath.Ext(fileHeader.Filename)) - if _, allowed := allowedExtensions[extension]; !allowed { + isVideo, allowed := allowedExtensions[extension] + if !allowed || (!isVideo && fileHeader.Size > maxUploadSize) || + (isVideo && fileHeader.Size > config.Spec.Global.UploadVideoMaxSize) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } @@ -76,6 +82,9 @@ func UploadFile(ctx *gin.Context) { ContentType: fileHeader.Header.Get("Content-Type"), Size: fileHeader.Size, }) + if claims, err := sdkmiddleware.ParseAuth(ctx); err == nil { + log.Printf("upload client=%s account=%s type=%s size=%d uri=%s", claims.Client, claims.Identity, fileHeader.Header.Get("Content-Type"), fileHeader.Size, "/uploads/"+datePath+"/"+filename) + } } // uploadRoot 返回本地 Mock 存储根目录;生产环境可通过环境变量映射到受控挂载目录。 diff --git a/backend/api/internal/models/cms_content_read.go b/backend/api/internal/models/cms_content_read.go new file mode 100644 index 0000000..49927c3 --- /dev/null +++ b/backend/api/internal/models/cms_content_read.go @@ -0,0 +1,22 @@ +package models + +import ( + "git.apinb.com/bsm-sdk/core/database" + "time" +) + +// CmsContentRead 保存用户对强制内容版本的阅读确认。 +type CmsContentRead struct { + Entity // 公共实体字段 + UserAccountID uint64 `gorm:"column:user_account_id;not null;uniqueIndex:idx_content_read_user_version" json:"user_account_id"` // 用户内部主键 + CmsContentID uint64 `gorm:"column:cms_content_id;not null;uniqueIndex:idx_content_read_user_version" json:"cms_content_id"` // 内容内部主键 + VersionNo int `gorm:"column:version_no;not null;uniqueIndex:idx_content_read_user_version" json:"version_no"` // 已确认内容版本 + ShownAt time.Time `gorm:"column:shown_at;type:timestamptz;not null" json:"shown_at"` // 客户端展示时间 + ConfirmedAt *time.Time `gorm:"column:confirmed_at;type:timestamptz" json:"confirmed_at"` // 用户确认时间 + ClientVersion string `gorm:"column:client_version;type:varchar(64);not null;default:''" json:"client_version"` // 客户端版本 + DeviceIdentity string `gorm:"column:device_identity;type:varchar(128);not null;default:''" json:"device_identity"` // 客户端设备标识 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex" json:"request_no"` // 确认幂等号 +} + +func init() { database.AppendMigrate(&CmsContentRead{}) } +func (*CmsContentRead) TableName() string { return "cms_content_read" } diff --git a/backend/api/internal/models/cs_ticket.go b/backend/api/internal/models/cs_ticket.go index 866aa4d..a1c865e 100644 --- a/backend/api/internal/models/cs_ticket.go +++ b/backend/api/internal/models/cs_ticket.go @@ -1,15 +1,29 @@ package models -import "git.apinb.com/bsm-sdk/core/database" +import ( + "git.apinb.com/bsm-sdk/core/database" + "time" +) // CsTicket 对应 cs_ticket,保存客服工单。 type CsTicket struct { - Entity // 公共实体字段 - TicketStatus int `gorm:"column:ticket_status;not null;default:32;index" json:"ticket_status"` // 工单业务状态 - TicketNo string `gorm:"column:ticket_no;type:varchar(64);not null;uniqueIndex" json:"ticket_no"` // ticket_no 业务字段 - UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 - Category string `gorm:"column:category;type:varchar(64);not null" json:"category"` // category 业务字段 - Priority string `gorm:"column:priority;type:varchar(16);not null;default:'normal'" json:"priority"` // priority 业务字段 + Entity // 公共实体字段 + TicketStatus int `gorm:"column:ticket_status;not null;default:32;index" json:"ticket_status"` // 工单业务状态 + TicketNo string `gorm:"column:ticket_no;type:varchar(64);not null;uniqueIndex" json:"ticket_no"` // ticket_no 业务字段 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;default:'';uniqueIndex:,where:request_no <> ''" json:"request_no"` // 创建幂等号 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 + StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"` // 分派工作人员内部主键 + GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 服务气站内部主键 + DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 关联配送点内部主键 + Category string `gorm:"column:category;type:varchar(64);not null" json:"category"` // category 业务字段 + Priority string `gorm:"column:priority;type:varchar(16);not null;default:'normal'" json:"priority"` // priority 业务字段 + Description string `gorm:"column:description;type:text;not null;default:''" json:"description"` // 用户问题描述 + Address string `gorm:"column:address;type:varchar(255);not null;default:''" json:"address"` // 上门地址快照 + AppointmentAt *time.Time `gorm:"column:appointment_at;type:timestamptz;index" json:"appointment_at"` // 预约服务时间 + StartedAt *time.Time `gorm:"column:started_at;type:timestamptz" json:"started_at"` // 开始处理时间 + CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 用户确认完成时间 + Result string `gorm:"column:result;type:text;not null;default:''" json:"result"` // 工作人员处理结果 + OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:'';index" json:"operator_identity"` // 最近操作人业务标识 } func init() { database.AppendMigrate(&CsTicket{}) } diff --git a/backend/api/internal/models/cs_ticket_evidence.go b/backend/api/internal/models/cs_ticket_evidence.go new file mode 100644 index 0000000..bd41756 --- /dev/null +++ b/backend/api/internal/models/cs_ticket_evidence.go @@ -0,0 +1,26 @@ +package models + +import ( + "git.apinb.com/bsm-sdk/core/database" + "time" +) + +// CsTicketEvidence 保存工单不可变现场证据。 +type CsTicketEvidence struct { + Entity // 公共实体字段 + CsTicketID uint64 `gorm:"column:cs_ticket_id;not null;index" json:"cs_ticket_id"` // 工单内部主键 + EvidenceType string `gorm:"column:evidence_type;type:varchar(32);not null;index" json:"evidence_type"` // 证据阶段或签名类型 + MediaType string `gorm:"column:media_type;type:varchar(16);not null" json:"media_type"` // 图片、视频或签名 + FileURI string `gorm:"column:file_uri;type:varchar(512);not null" json:"file_uri"` // 受控上传资源地址 + CapturedAt time.Time `gorm:"column:captured_at;type:timestamptz;not null;index" json:"captured_at"` // 客户端原始采集时间 + ReceivedAt time.Time `gorm:"column:received_at;type:timestamptz;not null;index" json:"received_at"` // 服务端接收时间 + 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"` // 采集纬度 + Source string `gorm:"column:source;type:varchar(32);not null;default:'app'" json:"source"` // 证据来源 + IntegrityStatus string `gorm:"column:integrity_status;type:varchar(32);not null;default:'unverified'" json:"integrity_status"` // 完整性校验状态 + OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;index" json:"operator_identity"` // 采集人员业务标识 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex" json:"request_no"` // 证据上报幂等号 +} + +func init() { database.AppendMigrate(&CsTicketEvidence{}) } +func (*CsTicketEvidence) TableName() string { return "cs_ticket_evidence" } diff --git a/backend/api/internal/models/ec_order.go b/backend/api/internal/models/ec_order.go index 78cf1c9..5af23fe 100644 --- a/backend/api/internal/models/ec_order.go +++ b/backend/api/internal/models/ec_order.go @@ -1,16 +1,36 @@ package models -import "git.apinb.com/bsm-sdk/core/database" +import ( + "git.apinb.com/bsm-sdk/core/database" + "time" +) // EcOrder 对应 ec_order,保存电商订单与组织快照。 type EcOrder struct { - Entity // 公共实体字段 - OrderStatus int `gorm:"column:order_status;not null;default:16;index" json:"order_status"` // 商城订单业务状态 - OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"` // order_no 业务字段 - UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 - GasStationID uint64 `gorm:"column:gas_station_id;not null;default:0;index" json:"gas_station_id"` // gas_station_id 业务字段 - DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;default:0;index" json:"delivery_point_id"` // delivery_point_id 业务字段 - TotalAmount int64 `gorm:"column:total_amount;not null;default:0;check:total_amount >= 0" json:"total_amount"` // total_amount 业务字段 + Entity // 公共实体字段 + OrderStatus int `gorm:"column:order_status;not null;default:16;index" json:"order_status"` // 商城订单业务状态 + OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"` // order_no 业务字段 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;default:'';uniqueIndex:,where:request_no <> ''" json:"request_no"` // 创建幂等号 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 + GasStationID uint64 `gorm:"column:gas_station_id;not null;default:0;index" json:"gas_station_id"` // gas_station_id 业务字段 + DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;default:0;index" json:"delivery_point_id"` // delivery_point_id 业务字段 + TotalAmount int64 `gorm:"column:total_amount;not null;default:0;check:total_amount >= 0" json:"total_amount"` // total_amount 业务字段 + UserAddressID uint64 `gorm:"column:user_address_id;not null;default:0;index" json:"user_address_id"` // 收货地址内部主键 + Address string `gorm:"column:address;type:varchar(255);not null;default:''" 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;default:''" json:"contact_name"` // 收货联系人快照 + ContactPhone string `gorm:"column:contact_phone;type:varchar(32);not null;default:''" json:"contact_phone"` // 收货电话快照 + ProductAmount int64 `gorm:"column:product_amount;not null;default:0;check:product_amount >= 0" json:"product_amount"` // 商品金额,单位分 + 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;default:0;check:payable_amount >= 0" json:"payable_amount"` // 应付金额,单位分 + PaidAt *time.Time `gorm:"column:paid_at;type:timestamptz" json:"paid_at"` // 支付完成时间 + Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 订单备注 + LogisticsNo string `gorm:"column:logistics_no;type:varchar(128);not null;default:'';index" json:"logistics_no"` // 物流单号 + LogisticsCompany string `gorm:"column:logistics_company;type:varchar(128);not null;default:''" json:"logistics_company"` // 物流公司 + LogisticsStatus int `gorm:"column:logistics_status;not null;default:10;index" json:"logistics_status"` // 独立物流状态 + ShippedAt *time.Time `gorm:"column:shipped_at;type:timestamptz" json:"shipped_at"` // 发货时间 + ReceivedAt *time.Time `gorm:"column:received_at;type:timestamptz" json:"received_at"` // 用户收货时间 } func init() { database.AppendMigrate(&EcOrder{}) } diff --git a/backend/api/internal/models/gasorder_track_point.go b/backend/api/internal/models/gasorder_track_point.go index bac1391..2665746 100644 --- a/backend/api/internal/models/gasorder_track_point.go +++ b/backend/api/internal/models/gasorder_track_point.go @@ -9,12 +9,16 @@ import ( // 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"` // 定位精度 + GasorderTrackID uint64 `gorm:"column:gasorder_track_id;not null;index" json:"gasorder_track_id"` // 轨迹自增主键 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;default:'';uniqueIndex:,where:request_no <> ''" json:"request_no"` // 上报幂等号 + 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"` // 定位发生时间 + ReceivedAt time.Time `gorm:"column:received_at;type:timestamptz;not null;index" json:"received_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"` // 定位精度 + Speed string `gorm:"column:speed;type:varchar(32);not null;default:''" json:"speed"` // 定位速度 + Direction string `gorm:"column:direction;type:varchar(32);not null;default:''" json:"direction"` // 定位方向 } func init() { database.AppendMigrate(&GasorderTrackPoint{}) } diff --git a/backend/api/internal/models/staff_account.go b/backend/api/internal/models/staff_account.go index dc1962d..d1ef134 100644 --- a/backend/api/internal/models/staff_account.go +++ b/backend/api/internal/models/staff_account.go @@ -5,15 +5,15 @@ import "git.apinb.com/bsm-sdk/core/database" // StaffAccount 对应 staff_account,是服务人员唯一的档案和 App 登录账户。 type StaffAccount struct { Entity // 公共实体字段 - Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录名称 - PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // 密码哈希 - Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 人员姓名 - Phone string `gorm:"column:phone;type:varchar(32);not null;default:'';index" json:"phone"` // 联系手机号 - Avatar string `gorm:"column:avatar;type:varchar(512);not null;default:''" json:"avatar"` // 头像资源地址 - RoleCode string `gorm:"column:role_code;type:varchar(64);not null;default:''" json:"role_code"` // 服务角色编码 - GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 所属可燃气体站主键 - DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 所属配送点主键 - WorkStatus string `gorm:"column:work_status;type:varchar(32);not null;default:'off_duty'" json:"work_status"` // 在岗接单状态 + Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录名称 + PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // 密码哈希 + Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 人员姓名 + Phone string `gorm:"column:phone;type:varchar(32);not null;default:'';uniqueIndex:idx_staff_account_phone,where:phone <> ''" json:"phone"` // 唯一登录手机号 + Avatar string `gorm:"column:avatar;type:varchar(512);not null;default:''" json:"avatar"` // 头像资源地址 + RoleCode string `gorm:"column:role_code;type:varchar(64);not null;default:''" json:"role_code"` // 服务角色编码 + GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 所属可燃气体站主键 + DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 所属配送点主键 + WorkStatus string `gorm:"column:work_status;type:varchar(32);not null;default:'off_duty'" json:"work_status"` // 在岗接单状态 } func init() { database.AppendMigrate(&StaffAccount{}) } diff --git a/backend/api/internal/models/staff_attendance.go b/backend/api/internal/models/staff_attendance.go new file mode 100644 index 0000000..6b29b9d --- /dev/null +++ b/backend/api/internal/models/staff_attendance.go @@ -0,0 +1,22 @@ +package models + +import ( + "git.apinb.com/bsm-sdk/core/database" + "time" +) + +// StaffAttendance 保存工作人员上下班不可变记录。 +type StaffAttendance struct { + Entity // 公共实体字段 + StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"` // 工作人员内部主键 + RoleCode string `gorm:"column:role_code;type:varchar(64);not null" json:"role_code"` // 打卡时岗位快照 + Action string `gorm:"column:action;type:varchar(16);not null;index" json:"action"` // 上班或下班动作 + OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null;index" json:"occurred_at"` // 客户端采集时间 + 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"` // 打卡纬度 + DeviceIdentity string `gorm:"column:device_identity;type:varchar(128);not null;default:''" json:"device_identity"` // 采集设备标识 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex" json:"request_no"` // 打卡幂等号 +} + +func init() { database.AppendMigrate(&StaffAttendance{}) } +func (*StaffAttendance) TableName() string { return "staff_attendance" } diff --git a/backend/api/internal/models/user_account.go b/backend/api/internal/models/user_account.go index c6107ec..17af545 100644 --- a/backend/api/internal/models/user_account.go +++ b/backend/api/internal/models/user_account.go @@ -5,12 +5,12 @@ import "git.apinb.com/bsm-sdk/core/database" // UserAccount 对应 user_account,是业主客户唯一的档案和用户端登录账户。 type UserAccount struct { Entity // 公共实体字段 - Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录名称 - PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // 密码哈希 - Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 客户姓名 - Phone string `gorm:"column:phone;type:varchar(32);not null;default:'';index" json:"phone"` // 联系手机号 - Avatar string `gorm:"column:avatar;type:varchar(512);not null;default:''" json:"avatar"` // 头像资源地址 - RealName string `gorm:"column:real_name;type:varchar(64);not null;default:''" json:"real_name"` // 实名认证名称 + Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录名称 + PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // 密码哈希 + Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 客户姓名 + Phone string `gorm:"column:phone;type:varchar(32);not null;default:'';uniqueIndex:idx_user_account_phone,where:phone <> ''" json:"phone"` // 唯一登录手机号 + Avatar string `gorm:"column:avatar;type:varchar(512);not null;default:''" json:"avatar"` // 头像资源地址 + RealName string `gorm:"column:real_name;type:varchar(64);not null;default:''" json:"real_name"` // 实名认证名称 } func init() { database.AppendMigrate(&UserAccount{}) } diff --git a/backend/api/internal/models/wallet_recharge_order.go b/backend/api/internal/models/wallet_recharge_order.go new file mode 100644 index 0000000..a3f79f7 --- /dev/null +++ b/backend/api/internal/models/wallet_recharge_order.go @@ -0,0 +1,23 @@ +package models + +import ( + "git.apinb.com/bsm-sdk/core/database" + "time" +) + +// WalletRechargeOrder 保存客户端钱包充值订单。 +type WalletRechargeOrder struct { + Entity // 公共实体字段 + RechargeStatus int `gorm:"column:recharge_status;not null;default:10;index" json:"recharge_status"` // 充值订单状态 + WalletBasicID uint64 `gorm:"column:wallet_basic_id;not null;index" json:"wallet_basic_id"` // 钱包内部主键 + RechargeNo string `gorm:"column:recharge_no;type:varchar(64);not null;uniqueIndex" json:"recharge_no"` // 充值订单号 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex" json:"request_no"` // 创建幂等号 + Amount int64 `gorm:"column:amount;not null;check:amount > 0" json:"amount"` // 充值金额,单位分 + Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` // 支付渠道 + OwnerType string `gorm:"column:owner_type;type:varchar(32);not null;index" json:"owner_type"` // 钱包主体类型 + OwnerIdentity string `gorm:"column:owner_identity;type:varchar(36);not null;index" json:"owner_identity"` // 钱包主体业务标识 + CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 支付完成时间 +} + +func init() { database.AppendMigrate(&WalletRechargeOrder{}) } +func (*WalletRechargeOrder) TableName() string { return "wallet_recharge_order" } diff --git a/backend/api/internal/routers/client.go b/backend/api/internal/routers/client.go new file mode 100644 index 0000000..870f7ce --- /dev/null +++ b/backend/api/internal/routers/client.go @@ -0,0 +1,86 @@ +package routers + +import ( + "fmt" + + sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware" + clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common" + stafflogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/staff" + userlogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/user" + "github.com/gin-gonic/gin" +) + +// RegisterClient 注册用户 App 和工作人员 App 的隔离 API。 +func RegisterClient(serviceKey string, engine *gin.Engine) { + registerUserClient(serviceKey, engine) + registerStaffClient(serviceKey, engine) +} + +func registerUserClient(serviceKey string, engine *gin.Engine) { + basePath := fmt.Sprintf("/%s/client/v1/user", serviceKey) + anonymous := engine.Group(basePath) + anonymous.POST("/auth/verification-code", clientcommon.SendVerificationCode("user_app")) + anonymous.POST("/auth/register", userlogic.Register) + anonymous.POST("/auth/login", userlogic.Login) + anonymous.POST("/auth/reset-password", userlogic.ResetPassword) + anonymous.GET("/public/gas-stations", userlogic.PublicGasStations) + anonymous.GET("/public/delivery-points", userlogic.PublicDeliveryPoints) + anonymous.GET("/public/contents", userlogic.PublicContents) + anonymous.GET("/public/products", userlogic.PublicProducts) + + protected := engine.Group(basePath) + protected.Use(sdkmiddleware.JwtAuth(true), clientcommon.RequireClient("user_app")) + protected.GET("/auth/profile", userlogic.Profile) + protected.PUT("/auth/profile", userlogic.UpdateProfile) + protected.PUT("/auth/password", userlogic.ChangePassword) + protected.GET("/addresses", userlogic.ListAddresses) + protected.POST("/addresses", userlogic.SaveAddress) + protected.POST("/contents/read-confirmations", userlogic.ConfirmContentRead) + protected.GET("/service-relation", userlogic.ServiceRelation) + protected.GET("/gas/contracts", userlogic.ListGasContracts) + protected.GET("/gas/orders", userlogic.ListGasOrders) + protected.POST("/gas/orders/:identity/cancel", userlogic.CancelGasOrder) + protected.GET("/tickets", userlogic.ListTickets) + protected.POST("/tickets", userlogic.CreateTicket) + protected.POST("/tickets/:identity/confirm", userlogic.ConfirmTicket) + protected.POST("/tickets/:identity/cancel", userlogic.CancelTicket) + protected.GET("/shop/orders", userlogic.ListShopOrders) + protected.POST("/shop/orders", userlogic.CreateShopOrder) + protected.POST("/shop/orders/:identity/cancel", userlogic.CancelShopOrder) + protected.POST("/shop/orders/:identity/pay", userlogic.PayShopOrder) + protected.POST("/shop/orders/:identity/confirm-receipt", userlogic.ConfirmShopReceipt) + registerClientWalletRoutes(protected, "user_app") +} + +func registerStaffClient(serviceKey string, engine *gin.Engine) { + basePath := fmt.Sprintf("/%s/client/v1/staff", serviceKey) + anonymous := engine.Group(basePath) + anonymous.POST("/auth/verification-code", clientcommon.SendVerificationCode("service_app")) + anonymous.POST("/auth/login", stafflogic.Login) + anonymous.POST("/auth/reset-password", stafflogic.ResetPassword) + + protected := engine.Group(basePath) + protected.Use(sdkmiddleware.JwtAuth(true), clientcommon.RequireClient("service_app")) + protected.GET("/auth/profile", stafflogic.Profile) + protected.PUT("/auth/password", stafflogic.ChangePassword) + protected.POST("/attendance", stafflogic.Attendance) + protected.GET("/tickets", stafflogic.ListTickets) + protected.POST("/tickets/:identity/start", stafflogic.StartTicket) + protected.POST("/tickets/:identity/exception", stafflogic.ExceptionTicket) + protected.POST("/tickets/:identity/recover", stafflogic.RecoverTicket) + protected.POST("/tickets/:identity/submit-result", stafflogic.SubmitTicketResult) + registerClientWalletRoutes(protected, "service_app") +} + +func registerClientWalletRoutes(group *gin.RouterGroup, client string) { + group.GET("/wallet", clientcommon.GetWallet(client)) + group.PUT("/wallet/payment-password", clientcommon.SetPaymentPassword(client)) + group.GET("/wallet/records", clientcommon.ListWalletRecords(client)) + group.POST("/wallet/recharges", clientcommon.CreateRecharge(client)) + group.POST("/wallet/recharges/:identity/mock-confirm", clientcommon.ConfirmMockRecharge(client)) + group.GET("/wallet/banks", clientcommon.ListBanks(client)) + group.POST("/wallet/banks", clientcommon.BindBank(client)) + group.DELETE("/wallet/banks/:identity", clientcommon.UnbindBank(client)) + group.GET("/wallet/withdrawals", clientcommon.ListWithdrawals(client)) + group.POST("/wallet/withdrawals", clientcommon.CreateWithdrawal(client)) +} diff --git a/backend/api/internal/routers/client_test.go b/backend/api/internal/routers/client_test.go new file mode 100644 index 0000000..0f52a89 --- /dev/null +++ b/backend/api/internal/routers/client_test.go @@ -0,0 +1,34 @@ +package routers + +import ( + "testing" + + "github.com/gin-gonic/gin" +) + +func TestRegisterClientRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + RegisterClient("heqi", engine) + + expected := map[string]bool{ + "POST /heqi/client/v1/user/auth/register": false, + "POST /heqi/client/v1/user/auth/login": false, + "POST /heqi/client/v1/user/wallet/recharges": false, + "POST /heqi/client/v1/user/shop/orders/:identity/pay": false, + "POST /heqi/client/v1/staff/auth/login": false, + "POST /heqi/client/v1/staff/attendance": false, + "POST /heqi/client/v1/staff/tickets/:identity/submit-result": false, + } + for _, route := range engine.Routes() { + key := route.Method + " " + route.Path + if _, ok := expected[key]; ok { + expected[key] = true + } + } + for route, found := range expected { + if !found { + t.Errorf("missing route %s", route) + } + } +} diff --git a/backend/api/internal/routers/register.go b/backend/api/internal/routers/register.go index eacd8f8..e3fb61e 100644 --- a/backend/api/internal/routers/register.go +++ b/backend/api/internal/routers/register.go @@ -8,5 +8,6 @@ func Register(serviceKey string, engine *gin.Engine) { RegisterPlatform(serviceKey, engine) RegisterGas(serviceKey, engine) RegisterDelivery(serviceKey, engine) + RegisterClient(serviceKey, engine) registerUploadRoute(serviceKey, engine) } diff --git a/backend/api/internal/routers/upload.go b/backend/api/internal/routers/upload.go index 9b014bf..555b6c3 100644 --- a/backend/api/internal/routers/upload.go +++ b/backend/api/internal/routers/upload.go @@ -1,11 +1,14 @@ package routers import ( + sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/upload" "github.com/gin-gonic/gin" ) // registerUploadRoute 注册已认证的文件上传接口。 func registerUploadRoute(serviceKey string, engine *gin.Engine) { - engine.POST("/upload/file", upload.UploadFile) + authorized := engine.Group("/upload") + authorized.Use(sdkmiddleware.JwtAuth(true)) + authorized.POST("/file", upload.UploadFile) } diff --git a/docs/03-用户端App需求.md b/docs/03-用户端App需求.md index 1b1ff96..f413f5e 100644 --- a/docs/03-用户端App需求.md +++ b/docs/03-用户端App需求.md @@ -90,3 +90,11 @@ Flutter App 使用底部导航:智能瓶阀控制、商城、收藏、订单 - 一键紧急关阀、告警静默时段(不影响高风险告警)、语音/无障碍辅助。 - 安全知识考试、服务评价、发票申请、预约改期、订阅式巡检服务。 - 后续可扩展设备蓝牙离线诊断、气瓶换新提醒、余量预测、异常用气提醒、电子保修卡和家庭成员用气对比。 + +## 6. 首期 Client API 落地边界(2026-07) + +- 用户端 API 固定为 `/heqi/client/v1/user`,令牌客户端为 `user_app`,不得与任何后台或工作人员令牌互用。 +- 首期实现手机号密码/验证码登录、普通注册、邀请注册、资料、地址、服务归属、已发布内容与阅读确认、商城下单和余额支付、物流查询/确认收货、供气合同及订单查询、工单、钱包充值/提现/银行卡。邀请注册可携带气站 `identity` 和可选配送点 `identity`,服务端在事务内校验组织关系并建立唯一服务归属。 +- 充值先创建待支付订单;仅开发配置允许 Mock 支付确认,确认后才写余额及不可变流水。微信和支付宝未配置渠道时必须明确返回不可用,不得模拟成功。 +- 商城订单交易状态与物流状态分离;物流单号、公司、发货和收货时间由服务端保存,用户只能查看本人订单并确认收货。 +- 首期不伪造设备控制、安全事件、押金、消息、发票、收藏、紧急联系人、账户注销和完整售后能力;文档中这些能力保留为后续迭代,不得以静态成功响应冒充已实现。 diff --git a/docs/04-服务端App需求.md b/docs/04-服务端App需求.md index 7984968..b8e4aa7 100644 --- a/docs/04-服务端App需求.md +++ b/docs/04-服务端App需求.md @@ -114,3 +114,12 @@ - 资质到期、培训考试、工具/车辆检查、评分、服务超时和异常率看板。 - 维修知识库、远程专家会诊、备件领用、语音转写、电子保修卡和用户回访。 + +## 8. 首期 Client API 落地边界(2026-07) + +- 工作人员 API 固定为 `/heqi/client/v1/staff`,令牌客户端为 `service_app`;不提供注册,只允许后台已创建、启用且岗位受支持的账户登录。 +- 首期岗位为配送、安装维修、安检。安装/维修工单只分派给安装维修人员,安检/复检只分派给安检人员,客服类工单不进入工作人员 App。 +- 工单统一复用 `cs_ticket`,状态为待分派、已分派、处理中、异常、待用户确认、已完成或已取消。工作人员只能操作分派给本人的工单;现场结果必须包含定位、原始采集时间、上传资源地址和幂等号。 +- 安装和维修至少提交前、中、后图片及用户签名;安检和复检至少提交一张图片、结果及用户签名。单次最多六张图片、三段视频。不合规或高风险结论只能进入异常,不能提交待用户确认。 +- 配送人员只操作分派给本人的供气配送订单,可开始配送、批量补传轨迹、到达校验、异常/恢复和提交签收;不能修改订单金额。到达以订单地址坐标和配置地理围栏为准。 +- 工作人员钱包与用户钱包复用统一模型,支持余额、充值订单、不可变流水、提现及银行卡;服务收入只能由已完成业务事实产生,客户端不能直接增加余额。 diff --git a/docs/11-数据接口与安全.md b/docs/11-数据接口与安全.md index d25c18c..5b3e4e3 100644 --- a/docs/11-数据接口与安全.md +++ b/docs/11-数据接口与安全.md @@ -99,3 +99,12 @@ - PostgreSQL 至少每日全量、持续 WAL 归档并定期演练恢复;安全事件、订单和资金数据定义更严格 RPO/RTO。 - 对象存储启用版本/生命周期策略,合同和安全证据按合规期限留存;备份不得绕开数据加密和访问控制。 - 关键服务多实例部署,MQTT、数据库、消息队列和对象存储须有明确高可用方案和故障演练计划。 + +## 6. 移动 Client API 安全实施约定 + +- 用户端和工作人员端分别使用 `user_app`、`service_app` JWT client claim;服务端逐请求校验 client、账户启用状态、岗位、组织和对象归属。 +- 验证码由 `/auth/verification-code` 创建,Redis 保存五分钟、验证成功即删除,并按手机号及来源 IP 限流;响应只返回请求 `identity` 和有效期。开发 Mock 验证码从 `Global` 配置读取,生产必须关闭。 +- 银行卡号、身份证号、预留手机号使用 `Global.FieldEncryptionKey` 经 HKDF 派生独立 AES-GCM 加密键和 HMAC 指纹键;接口列表只返回末四位掩码。开发占位密钥不得用于生产。 +- 支付密码独立于登录密码,仅允许六位数字,使用 bcrypt 保存;连续失败达到阈值后在 Redis 短时锁定。绑卡、解绑、余额支付和提现均要求支付密码或限定用途的一次性验证码。 +- 公共上传接口 `/upload/file` 必须携带平台、气站、配送点、用户或工作人员任一合法 JWT;图片/PDF 最大 10MB,视频上限从配置读取。上传只返回资源 URI,业务接口负责建立关联并记录操作者、采集与接收时间。 +- 充值、支付、提现、工单证据、轨迹点、内容确认等写入均携带幂等号;资金入账在数据库事务内锁定钱包并同时写不可变流水。