新增设备类型选项接口
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

This commit is contained in:
2026-09-22 12:35:26 +08:00
parent 22c3e7cc1a
commit 4330b8a40c
28 changed files with 671 additions and 8 deletions

View File

@@ -49,11 +49,12 @@ type ApplicationSubmitResult struct {
InstanceStatus int
}
// ApplicationService 创建与重提核销申请。
// ApplicationService 创建与重提核销申请,并恢复原审批提交事实
// 申请、审批尝试记录、审批实例与账单预占在同一事务完成;任一校验失败都不留下半成品事实。
type ApplicationService struct {
db *gorm.DB
approval approvalapp.Port
recovery approvalapp.RecoveryPort
audit ApplicationAuditWriter
}
@@ -62,6 +63,22 @@ func NewApplicationService(db *gorm.DB, approval approvalapp.Port, audit Applica
return &ApplicationService{db: db, approval: approval, audit: audit}
}
// SetRecoveryPort 注入通用审批原实例恢复接缝。
func (s *ApplicationService) SetRecoveryPort(recovery approvalapp.RecoveryPort) {
s.recovery = recovery
}
// RecoveryResult 返回原审批恢复动作的安全结果。
type RecoveryResult struct {
ApplicationID uint
ApprovalInstanceID uint
Action string
Result string
SubmissionStatus int
LastRecoveryAt *time.Time
Recoverable bool
}
// Create 为本人可见账单创建核销申请;超级管理员可为账单欠款人代办并必须填写代办原因。
func (s *ApplicationService) Create(ctx context.Context, command SubmitApplicationCommand) (*ApplicationSubmitResult, error) {
if err := s.ensureReady(); err != nil {
@@ -89,6 +106,81 @@ func (s *ApplicationService) Resubmit(ctx context.Context, applicationID uint, c
return s.submit(ctx, caller, applicationID, command)
}
// RecoverApproval 恢复员工核销申请的原审批事实,不创建新尝试或审批实例。
func (s *ApplicationService) RecoverApproval(ctx context.Context, applicationID uint) (*RecoveryResult, error) {
if applicationID == 0 {
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
actorID := middleware.GetUserIDFromContext(ctx)
if actorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeAgent || userType == constants.UserTypeEnterprise {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if s == nil || s.db == nil || s.recovery == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "核销审批恢复能力尚未配置")
}
var result RecoveryResult
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
application, err := lockApplication(ctx, tx, applicationID)
if err != nil {
return err
}
if application.ApplicantAccountID != actorID && userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
if application.Status != constants.EmployeeCollectionApplicationStatusPending || application.LatestAttemptID == 0 || application.LatestApprovalInstanceID == 0 {
return errors.New(errors.CodeConflict, "终态申请不可恢复")
}
var attempt model.EmployeeCollectionApplicationAttempt
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND application_id = ?", application.LatestAttemptID, application.ID).First(&attempt).Error; err != nil {
return errors.New(errors.CodeConflict, "核销审批尝试关联不一致")
}
if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != application.LatestApprovalInstanceID {
return errors.New(errors.CodeConflict, "核销审批实例关联不一致")
}
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND business_type = ? AND business_id = ?", application.LatestApprovalInstanceID, constants.ApprovalBusinessTypeEmployeeCollection, attempt.ID).First(&instance).Error; err != nil {
return errors.New(errors.CodeConflict, "审批实例业务关联不一致")
}
if instance.Status != constants.ApprovalStatusSubmitting && instance.Status != constants.ApprovalStatusPending && instance.Status != constants.ApprovalStatusSubmissionFailed && instance.Status != constants.ApprovalStatusSubmissionUnknown {
return errors.New(errors.CodeConflict, "审批终态不允许恢复")
}
var wc model.WeComApprovalContext
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("approval_instance_id = ? AND business_type = ?", instance.ID, constants.ApprovalBusinessTypeEmployeeCollection).First(&wc).Error; err != nil {
return errors.New(errors.CodeConflict, "企业微信审批上下文不存在")
}
beforeStatus := fmt.Sprintf("%d", wc.SubmissionStatus)
action, recoveryErr := "active", error(nil)
if wc.SPNo != "" {
action = "sp_no"
recoveryErr = s.recovery.EnqueueSubmittedSync(ctx, tx, instance.ID)
} else if wc.SubmissionStatus == constants.WeComSubmissionStatusUnknown || instance.Status == constants.ApprovalStatusSubmissionUnknown {
action = "unknown"
recoveryErr = s.recovery.EnqueueUnknownConfirm(ctx, tx, instance.ID)
} else if instance.Status == constants.ApprovalStatusSubmissionFailed && wc.SubmissionStatus == constants.WeComSubmissionStatusFailed {
action = "replay"
_, recoveryErr = s.recovery.RecoverSubmissionEvent(ctx, tx, instance.ID)
} else if instance.Status != constants.ApprovalStatusSubmitting && instance.Status != constants.ApprovalStatusPending && wc.SubmissionStatus != constants.WeComSubmissionStatusReady && wc.SubmissionStatus != constants.WeComSubmissionStatusSending {
return errors.New(errors.CodeConflict, "当前审批提交状态不允许恢复")
}
if recoveryErr != nil {
return errors.Wrap(errors.CodeConflict, recoveryErr, "恢复审批提交失败")
}
if err := s.audit.WriteEmployeeCollectionRecovery(ctx, tx, application, &attempt, &instance, actorID, beforeStatus, action, "accepted"); err != nil {
return err
}
result = RecoveryResult{ApplicationID: application.ID, ApprovalInstanceID: instance.ID, Action: action, Result: "accepted", SubmissionStatus: wc.SubmissionStatus, LastRecoveryAt: wc.LastRecoveryAt, Recoverable: true}
return nil
})
if err != nil {
return nil, err
}
return &result, nil
}
// ensureReady 校验用例依赖完整,缺失时失败关闭,避免绕过企业微信终审。
func (s *ApplicationService) ensureReady() error {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {

View File

@@ -34,4 +34,5 @@ type ApplicationAudit struct {
// ApplicationAuditWriter 在员工代收款核销事务内追加统一 Audit Event。
type ApplicationAuditWriter interface {
WriteEmployeeCollectionApplication(ctx context.Context, tx *gorm.DB, change ApplicationAudit) error
WriteEmployeeCollectionRecovery(ctx context.Context, tx *gorm.DB, application *model.EmployeeCollectionApplication, attempt *model.EmployeeCollectionApplicationAttempt, instance *model.ApprovalInstance, actorID uint, beforeStatus, action, result string) error
}

View File

@@ -17,6 +17,7 @@ import (
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
@@ -359,10 +360,11 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
PaymentMerchant: admin.NewPaymentMerchantHandler(merchantPaymentApp.NewManagementService(deps.DB, systemConfigAudit)),
EmployeeCollection: func() *admin.EmployeeCollectionHandler {
application := employeecollectionApp.NewApplicationService(deps.DB, svc.Approval, svc.AccessAudit)
application.SetRecoveryPort(approvalInfra.NewRecoveryPort(deps.DB, deps.QueueClient))
handler := admin.NewEmployeeCollectionHandler(
employeecollectionApp.NewPaymentMethodService(deps.DB, svc.AccessAudit),
employeecollectionApp.NewBillCloseService(deps.DB, svc.AccessAudit),
employeecollectionApp.NewApplicationService(deps.DB, svc.Approval, svc.AccessAudit),
employeecollectionApp.NewBillCloseService(deps.DB, svc.AccessAudit), application,
)
handler.SetPaymentMethodQuery(employeecollectionQuery.NewPaymentMethodQuery(deps.DB))
handler.SetBillQuery(employeecollectionQuery.NewBillQuery(deps.DB))

View File

@@ -35,6 +35,16 @@ func (h *DeviceHandler) List(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// ListDeviceTypes 查询设备类型选项。
// GET /api/admin/devices/types
func (h *DeviceHandler) ListDeviceTypes(c *fiber.Ctx) error {
result, err := h.service.ListDeviceTypes(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}
// BatchUpdateRealnamePolicy 批量更新设备实名认证策略。
// POST /api/admin/devices/batch-update-realname-policy
func (h *DeviceHandler) BatchUpdateRealnamePolicy(c *fiber.Ctx) error {

View File

@@ -9,6 +9,7 @@ import (
employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
employeecollectionquery "github.com/break/junhong_cmp_fiber/internal/query/employeecollection"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
@@ -268,6 +269,32 @@ func (h *EmployeeCollectionHandler) GetApplication(c *fiber.Ctx) error {
return response.Success(c, result)
}
// RecoverApplication 恢复核销申请的原审批提交事实。
// POST /api/admin/employee-collection-applications/:id/recover-approval
func (h *EmployeeCollectionHandler) RecoverApplication(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.application.RecoverApproval(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, &dto.EmployeeCollectionApplicationRecoveryResponse{
ApplicationID: result.ApplicationID, ApprovalInstanceID: result.ApprovalInstanceID,
Action: result.Action, Result: result.Result, SubmissionStatus: result.SubmissionStatus,
SubmissionStatusName: constants.GetWeComSubmissionStatusName(result.SubmissionStatus), Recoverable: result.Recoverable,
LastRecoveryAt: formatRecoveryTime(result.LastRecoveryAt),
})
}
func formatRecoveryTime(value *time.Time) string {
if value == nil {
return ""
}
return value.UTC().Format(time.RFC3339)
}
// bindApplicationRequest 绑定并转换创建或重提核销申请请求。
// 付款时间按带时区的 RFC3339 解析;其余边界与业务校验由应用用例统一判断。
func bindApplicationRequest(c *fiber.Ctx) (employeecollectionapp.SubmitApplicationCommand, error) {

View File

@@ -87,7 +87,7 @@ func (r *RecoveryPort) RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB,
}
func recoverableBusinessType(value string) bool {
return value == constants.ApprovalBusinessTypeRefund || value == constants.ApprovalBusinessTypeAgentDistribution
return value == constants.ApprovalBusinessTypeRefund || value == constants.ApprovalBusinessTypeAgentDistribution || value == constants.ApprovalBusinessTypeEmployeeCollection
}
var _ approvalapp.RecoveryPort = (*RecoveryPort)(nil)

View File

@@ -8,6 +8,7 @@ import (
employeecollection "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -83,3 +84,25 @@ func (w *Writer) WriteEmployeeCollectionApplication(ctx context.Context, tx *gor
Resources: resources,
})
}
// WriteEmployeeCollectionRecovery 记录人工恢复原审批的脱敏审计事实。
func (w *Writer) WriteEmployeeCollectionRecovery(ctx context.Context, tx *gorm.DB, application *model.EmployeeCollectionApplication, attempt *model.EmployeeCollectionApplicationAttempt, instance *model.ApprovalInstance, actorID uint, beforeStatus, action, result string) error {
if application == nil || application.ID == 0 || attempt == nil || attempt.ID == 0 || instance == nil || instance.ID == 0 || actorID == 0 {
return errors.New(errors.CodeInvalidParam, "核销审批恢复审计资源不完整")
}
applicationID := strconv.FormatUint(uint64(application.ID), 10)
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
instanceID := strconv.FormatUint(uint64(instance.ID), 10)
return w.Append(ctx, tx, AppendInput{
EventID: "employee_collection:application:" + applicationID + ":recover:" + action,
ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "人工恢复员工核销原审批提交",
Actor: ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(actorID), 10)}, Source: constants.AuditSourceAdminAPI,
ScopeType: constants.AuditScopePlatform, Result: result,
Metadata: map[string]any{"before_submission_status": beforeStatus, "action": action, "result": result},
Resources: []ResourceInput{
{Type: constants.AuditResourceEmployeeCollectionApplication, ID: &applicationID, Key: applicationID, DisplayName: "核销申请 " + applicationID, Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCollectionApplication, IdentitySnapshot: map[string]any{"id": application.ID, "applicant_account_id": application.ApplicantAccountID}},
{Type: constants.AuditResourceEmployeeCollectionAttempt, ID: &attemptID, Key: attemptID, DisplayName: "审批尝试 " + attemptID, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCollectionAttempt, IdentitySnapshot: map[string]any{"id": attempt.ID, "application_id": attempt.ApplicationID}},
{Type: constants.AuditResourceApprovalInstance, ID: &instanceID, Key: instanceID, DisplayName: "审批实例 " + instanceID, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalTarget, IdentitySnapshot: map[string]any{"id": instance.ID, "business_type": instance.BusinessType, "business_id": instance.BusinessID}},
},
})
}

View File

@@ -73,6 +73,10 @@ type ListDeviceResponse struct {
PageSize int `json:"size" description:"每页数量"`
}
// DeviceTypeOptionsResponse 设备类型选项响应。
// 响应数据为去重、去除首尾空白并按升序排列的设备类型字符串数组。
type DeviceTypeOptionsResponse []string
type GetDeviceRequest struct {
ID uint `path:"id" description:"设备ID" required:"true"`
}

View File

@@ -293,6 +293,12 @@ type EmployeeCollectionApplicationResponse struct {
StatusName string `json:"status_name" description:"申请状态中文名称"`
LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID仅用于展示"`
LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID仅用于展示"`
ApprovalSubmissionStatus int `json:"approval_submission_status" description:"审批提交状态"`
ApprovalSubmissionName string `json:"approval_submission_name" description:"审批提交状态中文名称"`
ApprovalFailureSummary string `json:"approval_failure_summary,omitempty" description:"脱敏审批失败摘要"`
Recoverable bool `json:"recoverable" description:"是否允许恢复原审批"`
RecoveryResult string `json:"recovery_result,omitempty" description:"最近一次恢复动作结果"`
LastRecoveryAt string `json:"last_recovery_at,omitempty" description:"最近恢复时间"`
DecidedAt *time.Time `json:"decided_at,omitempty" description:"审批终态到达时间"`
TerminalReason string `json:"terminal_reason" description:"异常终态说明,如企业微信通过后撤销"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
@@ -341,3 +347,15 @@ type EmployeeCollectionApplicationDetailResponse struct {
Allocations []*EmployeeCollectionApplicationAllocationResponse `json:"allocations" description:"该申请当前的账单分摊"`
Attempts []*EmployeeCollectionBillAttemptResponse `json:"attempts" description:"全部审批尝试记录,按提交顺序排列,历史材料不被覆盖"`
}
// EmployeeCollectionApplicationRecoveryResponse 返回人工恢复原审批结果。
type EmployeeCollectionApplicationRecoveryResponse struct {
ApplicationID uint `json:"application_id" description:"核销申请ID"`
ApprovalInstanceID uint `json:"approval_instance_id" description:"原审批实例ID"`
Action string `json:"action" description:"恢复动作"`
Result string `json:"result" description:"恢复结果"`
SubmissionStatus int `json:"submission_status" description:"审批提交状态"`
SubmissionStatusName string `json:"submission_status_name" description:"审批提交状态中文名称"`
Recoverable bool `json:"recoverable" description:"是否仍可恢复"`
LastRecoveryAt string `json:"last_recovery_at,omitempty" description:"最近恢复时间"`
}

View File

@@ -6,12 +6,31 @@ import (
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, importHandler *admin.DeviceImportHandler, doc *openapi.Generator, basePath string) {
devices := router.Group("/devices")
groupPath := basePath + "/devices"
platformDevices := router.Group("/devices", func(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return c.Next()
})
Register(platformDevices, doc, groupPath, "GET", "/types", handler.ListDeviceTypes, RouteSpec{
Summary: "查询设备类型选项",
Description: "仅超级管理员与平台账号可访问。返回未删除设备中去重、去除首尾空白且按升序排列的非空设备类型字符串。",
Tags: []string{"设备管理"},
Input: nil,
Output: new(dto.DeviceTypeOptionsResponse),
Auth: true,
})
Register(devices, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "设备列表",

View File

@@ -155,4 +155,7 @@ func registerEmployeeCollectionApplicationRoutes(router fiber.Router, handler *a
Output: new(dto.EmployeeCollectionApplicationSubmitResponse),
Auth: true,
})
Register(group, doc, applicationPath, "POST", "/:id/recover-approval", handler.RecoverApplication, RouteSpec{
Summary: "恢复核销原审批", Description: "复用最新审批尝试与原审批实例,按企业微信提交事实恢复或查询确认,不创建新审批。", Tags: []string{"员工代收款"}, Input: new(dto.IDReq), Output: new(dto.EmployeeCollectionApplicationRecoveryResponse), Auth: true,
})
}

View File

@@ -324,6 +324,18 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
}, nil
}
// ListDeviceTypes 查询平台管理端可用的设备类型选项。
func (s *Service) ListDeviceTypes(ctx context.Context) ([]string, error) {
types, err := s.deviceStore.ListDeviceTypes(ctx)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备类型选项失败")
}
if types == nil {
types = make([]string, 0)
}
return types, nil
}
func normalizeShopIDs(ids []uint) ([]uint, bool) {
if len(ids) == 0 {
return nil, false

View File

@@ -142,6 +142,23 @@ func (s *DeviceStore) List(ctx context.Context, opts *store.QueryOptions, filter
return devices, total, nil
}
// ListDeviceTypes 查询未删除设备的去重设备类型。
func (s *DeviceStore) ListDeviceTypes(ctx context.Context) ([]string, error) {
types := make([]string, 0)
trimmedType := "TRIM(device_type)"
err := s.db.WithContext(ctx).
Model(&model.Device{}).
Where("NULLIF("+trimmedType+", '') IS NOT NULL").
Select(trimmedType).
Distinct().
Order(trimmedType+" ASC").
Pluck(trimmedType, &types).Error
if err != nil {
return nil, err
}
return types, nil
}
func (s *DeviceStore) applyDeviceFilters(ctx context.Context, query *gorm.DB, filters map[string]any) *gorm.DB {
if virtualNo, ok := filters["virtual_no"].(string); ok && virtualNo != "" {
query = query.Where("virtual_no LIKE ?", "%"+virtualNo+"%")