feat(api): add client app service endpoints
This commit is contained in:
143
backend/api/internal/logic/client/common/auth.go
Normal file
143
backend/api/internal/logic/client/common/auth.go
Normal file
@@ -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())
|
||||
}
|
||||
43
backend/api/internal/logic/client/common/security_test.go
Normal file
43
backend/api/internal/logic/client/common/security_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
476
backend/api/internal/logic/client/common/wallet.go
Normal file
476
backend/api/internal/logic/client/common/wallet.go
Normal file
@@ -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
|
||||
}
|
||||
123
backend/api/internal/logic/client/staff/auth.go
Normal file
123
backend/api/internal/logic/client/staff/auth.go
Normal file
@@ -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})
|
||||
}
|
||||
226
backend/api/internal/logic/client/staff/work.go
Normal file
226
backend/api/internal/logic/client/staff/work.go
Normal file
@@ -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})
|
||||
}
|
||||
165
backend/api/internal/logic/client/user/address_ticket.go
Normal file
165
backend/api/internal/logic/client/user/address_ticket.go
Normal file
@@ -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})
|
||||
}
|
||||
211
backend/api/internal/logic/client/user/auth.go
Normal file
211
backend/api/internal/logic/client/user/auth.go
Normal file
@@ -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})
|
||||
}
|
||||
92
backend/api/internal/logic/client/user/basic.go
Normal file
92
backend/api/internal/logic/client/user/basic.go
Normal file
@@ -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 }
|
||||
86
backend/api/internal/logic/client/user/gasorder.go
Normal file
86
backend/api/internal/logic/client/user/gasorder.go
Normal file
@@ -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})
|
||||
}
|
||||
219
backend/api/internal/logic/client/user/shop.go
Normal file
219
backend/api/internal/logic/client/user/shop.go
Normal file
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user