已完成用户APP首期功能开发
交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
// 功能描述:用户地址列表及本人客服工单创建、查询、确认和取消。
|
||||
// 版本:1.1.0。
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -11,6 +14,7 @@ import (
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ListAddresses 返回当前用户未归档地址。
|
||||
@@ -27,41 +31,6 @@ func ListAddresses(ctx *gin.Context) {
|
||||
infra.Response.Success(ctx, common.ResourceResponse(list))
|
||||
}
|
||||
|
||||
// SaveAddress 新增地址,设为默认时原默认地址会在同一事务取消默认。
|
||||
func SaveAddress(ctx *gin.Context) {
|
||||
account, ok := common.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: common.NewEntity(common.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,
|
||||
}
|
||||
@@ -73,35 +42,64 @@ func CreateTicket(ctx *gin.Context) {
|
||||
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"`
|
||||
RequestNo string `json:"request_no" binding:"required,max=128"`
|
||||
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"`
|
||||
FaultType string `json:"fault_type" binding:"omitempty,oneof=leak valve alarm other"`
|
||||
Photos []ticketPhotoRequest `json:"photos" binding:"max=3,dive"`
|
||||
}
|
||||
if ctx.ShouldBindJSON(&request) != nil || !userTicketCategories[request.Category] {
|
||||
if ctx.ShouldBindJSON(&request) != nil || !userTicketCategories[request.Category] || strings.TrimSpace(request.Description) == "" || strings.TrimSpace(request.RequestNo) == "" {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
var relation models.UserServiceRelation
|
||||
_ = impl.DBService.Where("user_account_id = ? AND status = ?", account.ID, common.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, common.StatusArchived).First(&address).Error != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
|
||||
return
|
||||
var ticket models.CsTicket
|
||||
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
// 同一账户串行创建;重试先返回首次事实,不受地址后续修改或删除影响。
|
||||
if err := lockAddressOwner(tx, account.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
addressText = address.Address
|
||||
}
|
||||
ticket := models.CsTicket{
|
||||
Entity: common.NewEntity(common.StatusEnable), TicketStatus: 32, TicketNo: common.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 {
|
||||
err := tx.Where("request_no = ? AND user_account_id = ?", request.RequestNo, account.ID).First(&ticket).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if request.AppointmentAt != nil && request.AppointmentAt.Before(time.Now()) {
|
||||
return errcode.ErrInvalidArgument
|
||||
}
|
||||
var relation models.UserServiceRelation
|
||||
if err := tx.Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable).First(&relation).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
addressText, contactName, contactPhone := "", account.Name, account.Phone
|
||||
if request.AddressIdentity != "" {
|
||||
var address models.UserAddress
|
||||
if err := tx.Where("identity = ? AND user_account_id = ? AND status <> ?", request.AddressIdentity, account.ID, common.StatusArchived).First(&address).Error; err != nil {
|
||||
return errcode.ErrRecordNotFound
|
||||
}
|
||||
addressText = address.Address
|
||||
if address.ContactName != "" {
|
||||
contactName = address.ContactName
|
||||
}
|
||||
if address.ContactPhone != "" {
|
||||
contactPhone = address.ContactPhone
|
||||
}
|
||||
}
|
||||
ticket = models.CsTicket{
|
||||
Entity: common.NewEntity(common.StatusEnable), TicketStatus: 32, TicketNo: common.RecordNo("TK"), RequestNo: request.RequestNo,
|
||||
UserAccountID: account.ID, GasBasicID: relation.GasBasicID, DeliveryBasicID: relation.DeliveryBasicID,
|
||||
Category: request.Category, Priority: "normal", Description: strings.TrimSpace(request.Description), FaultType: request.FaultType,
|
||||
Address: addressText, ContactName: contactName, ContactPhone: contactPhone, AppointmentAt: request.AppointmentAt, OperatorIdentity: account.Identity,
|
||||
}
|
||||
if err := tx.Create(&ticket).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return saveTicketPhotos(tx, ticket, account.Identity, request.Photos)
|
||||
})
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
@@ -119,46 +117,84 @@ func ListTickets(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, common.ResourceResponse(list))
|
||||
items := make([]any, 0, len(list))
|
||||
photos, err := ticketPhotosForList(list)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
for _, ticket := range list {
|
||||
item := common.ResourceResponse(ticket).(map[string]any)
|
||||
item["status_name"], item["allowed_actions"] = ticketState(ticket.TicketStatus)
|
||||
item["photos"] = photos[ticket.ID]
|
||||
items = append(items, item)
|
||||
}
|
||||
infra.Response.Success(ctx, items)
|
||||
}
|
||||
|
||||
// ConfirmTicket 用户确认工作人员提交的处理结果。
|
||||
// ticketState 发布已有状态机允许的动作;未知状态不授予任何写权限。
|
||||
func ticketState(status int) (string, []string) {
|
||||
names := map[int]string{32: "待受理", 18: "已派单", 11: "处理中", 21: "处理异常", 34: "待确认", 23: "已完成", 22: "已取消"}
|
||||
name, exists := names[status]
|
||||
if !exists {
|
||||
name = "状态待核实"
|
||||
}
|
||||
actions := []string{}
|
||||
switch status {
|
||||
case 32, 18, 11, 21, 34:
|
||||
actions = append(actions, "cancel")
|
||||
}
|
||||
if status == 34 {
|
||||
actions = append(actions, "confirm")
|
||||
}
|
||||
return name, actions
|
||||
}
|
||||
|
||||
// ConfirmTicket 用户确认工作人员提交的处理结果,重复确认不覆盖完成时间。
|
||||
func ConfirmTicket(ctx *gin.Context) {
|
||||
account, ok := common.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})
|
||||
changeTicketState(ctx, "confirm", 23)
|
||||
}
|
||||
|
||||
// CancelTicket 取消尚未完成的本人工单。
|
||||
func CancelTicket(ctx *gin.Context) {
|
||||
changeTicketState(ctx, "cancel", 22)
|
||||
}
|
||||
|
||||
// changeTicketState 锁定本人工单并校验状态,归档和其他用户的记录不可操作。
|
||||
func changeTicketState(ctx *gin.Context, action string, target int) {
|
||||
account, ok := common.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)
|
||||
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var ticket models.CsTicket
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND user_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).First(&ticket).Error; err != nil {
|
||||
return errcode.ErrRecordNotFound
|
||||
}
|
||||
if ticket.TicketStatus == target {
|
||||
return nil
|
||||
}
|
||||
_, actions := ticketState(ticket.TicketStatus)
|
||||
allowed := false
|
||||
for _, candidate := range actions {
|
||||
allowed = allowed || candidate == action
|
||||
}
|
||||
if !allowed {
|
||||
return errcode.ErrInvalidArgument
|
||||
}
|
||||
values := map[string]any{"ticket_status": target, "operator_identity": account.Identity}
|
||||
if action == "confirm" {
|
||||
values["completed_at"] = time.Now()
|
||||
}
|
||||
return tx.Model(&ticket).Updates(values).Error
|
||||
})
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
if action == "confirm" {
|
||||
infra.Response.Success(ctx, gin.H{"confirmed": true})
|
||||
} else {
|
||||
infra.Response.Success(ctx, gin.H{"cancelled": true})
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"cancelled": true})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user