feat(api): add client app service endpoints

This commit is contained in:
david
2026-07-30 18:04:34 +08:00
parent 47d352b22e
commit 550efb3812
29 changed files with 2147 additions and 44 deletions

View 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})
}

View 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})
}

View 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 }

View 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})
}

View 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})
}