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

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