From 4330b8a40c5a2a39fd8badf33752b2a0924b5875 Mon Sep 17 00:00:00 2001 From: break Date: Tue, 22 Sep 2026 12:35:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AE=BE=E5=A4=87=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E9=80=89=E9=A1=B9=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entry-capability-requirement-matrix.json | 9 ++ .../employeecollection/application.go | 94 ++++++++++++++++++- .../employeecollection/application_audit.go | 1 + internal/bootstrap/handlers.go | 6 +- internal/handler/admin/device.go | 10 ++ internal/handler/admin/employee_collection.go | 27 ++++++ internal/infrastructure/approval/recovery.go | 2 +- .../audit/employee_collection_application.go | 23 +++++ internal/model/dto/device_dto.go | 4 + internal/model/dto/employee_collection_dto.go | 18 ++++ internal/routes/device.go | 19 ++++ internal/routes/employee_collection.go | 3 + internal/service/device/service.go | 12 +++ internal/store/postgres/device_store.go | 17 ++++ .../.openspec.yaml | 2 + .../design.md | 82 ++++++++++++++++ .../proposal.md | 27 ++++++ .../specs/employee-collection-bill/spec.md | 45 +++++++++ .../tasks.md | 27 ++++++ .../.openspec.yaml | 2 + .../design.md | 64 +++++++++++++ .../proposal.md | 28 ++++++ .../specs/device-type-options/spec.md | 53 +++++++++++ .../tasks.md | 18 ++++ openspec/specs/device-type-options/spec.md | 55 +++++++++++ .../specs/employee-collection-bill/spec.md | 2 +- pkg/constants/wecom.go | 18 ++++ pkg/openapi/generator.go | 11 ++- 28 files changed, 671 insertions(+), 8 deletions(-) create mode 100644 openspec/changes/archive/2026-09-21-recover-employee-collection-approval/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-21-recover-employee-collection-approval/design.md create mode 100644 openspec/changes/archive/2026-09-21-recover-employee-collection-approval/proposal.md create mode 100644 openspec/changes/archive/2026-09-21-recover-employee-collection-approval/specs/employee-collection-bill/spec.md create mode 100644 openspec/changes/archive/2026-09-21-recover-employee-collection-approval/tasks.md create mode 100644 openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/design.md create mode 100644 openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/proposal.md create mode 100644 openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/specs/device-type-options/spec.md create mode 100644 openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/tasks.md create mode 100644 openspec/specs/device-type-options/spec.md diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index 368d301c..fc7216a9 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -4034,6 +4034,15 @@ ], "classification": "behavior" }, + { + "entry_type": "http", + "entry": "POST /api/admin/employee-collection-applications/{id}/recover-approval", + "capability": "employee-collection-bill", + "requirements": [ + "employee-collection-bill::核销申请审批、重提与幂等" + ], + "classification": "behavior" + }, { "entry_type": "http", "entry": "GET /api/admin/agent-self-recharge-payment-methods", diff --git a/internal/application/employeecollection/application.go b/internal/application/employeecollection/application.go index 07bcb2e0..073128ed 100644 --- a/internal/application/employeecollection/application.go +++ b/internal/application/employeecollection/application.go @@ -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 { diff --git a/internal/application/employeecollection/application_audit.go b/internal/application/employeecollection/application_audit.go index d47a5651..e10aedea 100644 --- a/internal/application/employeecollection/application_audit.go +++ b/internal/application/employeecollection/application_audit.go @@ -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 } diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index ffe5b4e0..83af3fc3 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -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)) diff --git a/internal/handler/admin/device.go b/internal/handler/admin/device.go index 85b49b65..3575cb77 100644 --- a/internal/handler/admin/device.go +++ b/internal/handler/admin/device.go @@ -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 { diff --git a/internal/handler/admin/employee_collection.go b/internal/handler/admin/employee_collection.go index 9bf69382..c23583ee 100644 --- a/internal/handler/admin/employee_collection.go +++ b/internal/handler/admin/employee_collection.go @@ -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) { diff --git a/internal/infrastructure/approval/recovery.go b/internal/infrastructure/approval/recovery.go index d90b8400..ff9d7217 100644 --- a/internal/infrastructure/approval/recovery.go +++ b/internal/infrastructure/approval/recovery.go @@ -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) diff --git a/internal/infrastructure/audit/employee_collection_application.go b/internal/infrastructure/audit/employee_collection_application.go index e7177161..13b37020 100644 --- a/internal/infrastructure/audit/employee_collection_application.go +++ b/internal/infrastructure/audit/employee_collection_application.go @@ -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}}, + }, + }) +} diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index d7a5f45c..476b174b 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -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"` } diff --git a/internal/model/dto/employee_collection_dto.go b/internal/model/dto/employee_collection_dto.go index eec098f8..b762e140 100644 --- a/internal/model/dto/employee_collection_dto.go +++ b/internal/model/dto/employee_collection_dto.go @@ -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:"最近恢复时间"` +} diff --git a/internal/routes/device.go b/internal/routes/device.go index 2fa065ed..a68302cb 100644 --- a/internal/routes/device.go +++ b/internal/routes/device.go @@ -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: "设备列表", diff --git a/internal/routes/employee_collection.go b/internal/routes/employee_collection.go index 9a057d0c..2efeec66 100644 --- a/internal/routes/employee_collection.go +++ b/internal/routes/employee_collection.go @@ -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, + }) } diff --git a/internal/service/device/service.go b/internal/service/device/service.go index c05b8de7..2cf0b37b 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -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 diff --git a/internal/store/postgres/device_store.go b/internal/store/postgres/device_store.go index 1d9b6e20..f0668d82 100644 --- a/internal/store/postgres/device_store.go +++ b/internal/store/postgres/device_store.go @@ -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+"%") diff --git a/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/.openspec.yaml b/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/.openspec.yaml new file mode 100644 index 00000000..cbd245e4 --- /dev/null +++ b/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-20 diff --git a/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/design.md b/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/design.md new file mode 100644 index 00000000..fbca2455 --- /dev/null +++ b/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/design.md @@ -0,0 +1,82 @@ +## Context + +现有员工核销提交在同一事务内创建不可变审批尝试、通用审批实例、企业微信上下文和稳定的审批提交 Outbox,申请保存最新尝试与最新实例用于展示。主 Spec 已要求提交失败、回调延迟或结果未知时保持在途并使用查询/恢复机制确认,但当前没有面向员工或管理员的恢复入口,也没有可供页面稳定判断的恢复投影。 + +通用审批提交事件键为 `approval:{instance_id}:submission`。恢复若绕过该事实直接创建新审批,会在“企微已受理、本地结果未知”时产生重复审批单;若释放账单预占再重建申请,则会改变业务材料与并发余额事实。因此恢复必须落在既有审批实例和可靠投递边界内。 + +## Goals / Non-Goals + +**Goals:** + +- 为员工核销建立统一、幂等的原审批恢复用例,覆盖明确失败、结果未知和已有企微单号待同步三类状态。 +- 让原申请员工与有权管理员共享同一恢复规则,同时保持原申请人为企微发起人。 +- 把可恢复性和安全失败摘要作为读侧投影返回,避免前端复制审批状态机。 +- 保持审批尝试、申请材料、账单预占和审计事实可追溯。 + +**Non-Goals:** + +- 不创建新的审批尝试或审批实例,不提供“强制重新提交”或本地人工终审。 +- 不允许通过恢复入口修改核销材料、附件、分摊或收款方式,也不释放账单预占。 +- 不改变企业微信提交、查询、回调和终态消费的既有渠道契约。 +- 不为退款、代理充值或提现同时新增同类入口;通用恢复接缝可复用,但本 Change 只开放员工核销。 + +## Decisions + +### 统一恢复用例只接受核销申请标识 + +新增 `POST /api/admin/employee-collection-applications/{id}/recover-approval`。Handler 只绑定申请 ID,Application 用例在事务内锁定申请和最新审批尝试,并校验申请仍为审批中、最新实例引用一致及调用方权限。请求不接受审批实例 ID、提交人或材料,避免调用方替换恢复目标。 + +选择单一业务动作而不是分别暴露“重新提交”和“查询状态”:调用方无法可靠知道企业微信是否已受理,分裂入口会把结果未知判定泄漏给前端并放大重复提交风险。 + +### 恢复决策以原审批实例的渠道事实为准 + +用例读取通用审批实例、企业微信上下文及稳定提交事件,按固定优先级决策: + +1. 已有企业微信审批单号:只请求查询同步原审批单。 +2. 无审批单号且提交结果未知:恢复或触发既有结果确认流程,禁止直接创建提交。 +3. 无审批单号且明确失败,并能确认未受理:把同一稳定事件恢复为可投递状态。 +4. 事件正在等待、重试或处理:不写重复事件,返回正在恢复。 +5. 申请、实例或企微审批已终态:返回状态冲突。 + +恢复实际外部动作继续由 Worker 执行,HTTP 请求只原子恢复可靠事实或触发查询事实,不同步调用企微创建接口。这样 HTTP 超时不会造成调用方误认为失败后再次提交。 + +备选是在接口内同步查询或提交;放弃,因为请求超时和进程中断会重新引入结果未知窗口。 + +### 稳定事件原位恢复而非新增补偿事件 + +继续使用 `approval:{instance_id}:submission` 作为唯一提交业务键。对终态失败或耗尽重试的事件执行带当前状态谓词的条件更新,恢复其投递资格和必要的租约字段;对活动事件返回幂等结果。查询同步复用原审批实例的稳定查询/恢复事实,不生成另一个“人工恢复提交”事件键。 + +并发员工和管理员操作通过审批实例行锁与事件条件更新收敛,最多一个请求改变状态。恢复审计可每次记录触发动作,但业务提交事实保持唯一。 + +### 权限与目标实例使用确定规则 + +原申请员工可恢复本人申请;超级管理员和平台用户可代为恢复;代理、企业账号和其他账号拒绝。越权与不存在保持不可区分。管理员仅作为实际操作者写审计,审批实例中的提交人和企业微信发起身份不变。 + +恢复目标固定为申请最新尝试与最新实例,并校验业务类型为 `employee_collection_approval`、业务标识等于最新尝试标识、尝试关联实例一致。任一引用不一致都返回状态冲突,不按申请主表或其它候选实例猜测恢复目标。 + +### 可恢复性由服务端投影 + +核销申请列表和详情增加提交状态、可恢复标记、安全失败摘要及最近恢复时间。投影由申请状态、最新实例、企微上下文和提交事件批量计算;列表不得逐条查询放大。失败摘要使用稳定分类和脱敏信息,不返回附件、完整流水、企微原文或内部队列键。 + +现有企业微信审批上下文已保存 `last_recovery_at`,可直接作为最近渠道恢复时间;人工触发时间、操作者和动作结果由新增核销恢复审计表达,因此不新增业务列或数据库迁移,也不得使用通用 `updated_at` 冒充恢复时间。 + +### 恢复失败不改变业务申请与预占 + +身份、场景、模板映射或渠道暂不可用时,恢复返回明确可处置原因并保留原申请、审批实例和账单预占。配置修复后可再次恢复同一事实。数据库或事件状态更新失败时事务回滚,不形成“已记录恢复但事件未恢复”的分裂状态。 + +每次人工恢复记录申请、审批实例、操作者、触发前提交状态、选择的恢复动作与结果;审计禁止保存支付凭证、完整外部交易流水号和企微响应原文。 + +## Risks / Trade-offs + +- [平台用户代办可能扩大既有核销写权限] → 恢复入口是独立受控动作,仅允许平台用户恢复原审批事实;不得据此放宽列表、详情、创建、重提或本地终审等其它核销权限。 +- [Outbox 终态失败与结果未知目前使用相同状态] → 以企业微信外部单号、提交认领事实和集成结果联合判定;无法证明未受理时一律走查询确认,绝不直接重提。 +- [列表投影联查增加查询成本] → 按页批量读取最新审批实例、企微上下文和事件状态,禁止逐条查询。 +- [恢复事件后配置仍不可用导致反复失败] → 返回稳定失败分类并保留可再次恢复资格;恢复不增加审批单或账单预占。 +- [人工恢复与 Worker 正常重试竞争] → 条件更新和租约状态确保只有一个执行获得处理权,其余返回当前状态。 + +## Migration Plan + +1. 复用企业微信审批上下文既有 `last_recovery_at` 与审计事件时间表达恢复事实,不执行数据库迁移。 +2. 发布支持恢复接缝、员工核销恢复用例、读侧投影和新路由的 API/Worker 二进制。 +3. 在维护者指定测试环境验证明确失败、结果未知、已有企微单号、重复与并发恢复,核对企微审批单数量、尝试数量和账单预占均不增加。 +4. 回滚时停止开放恢复入口并回退二进制;已恢复的原提交事件继续按既有可靠投递语义处理,不删除审批、申请或审计事实。 diff --git a/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/proposal.md b/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/proposal.md new file mode 100644 index 00000000..d62523b8 --- /dev/null +++ b/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/proposal.md @@ -0,0 +1,27 @@ +## Why + +员工核销申请因企业微信或提交链路异常未成功提交、提交失败或结果未知后,当前没有面向员工和管理员的安全恢复入口,导致申请持续占用账单可核销金额且无法继续审批。恢复必须复用原审批实例与稳定提交事实,避免企业微信已受理但本地未知时生成重复审批单。 + +## What Changes + +- 为待审批的员工核销申请新增“恢复原审批”操作,允许原申请员工以及其数据范围内的超级管理员、平台用户触发。 +- 恢复操作复用最新审批尝试、原审批实例和稳定提交事件,不修改申请材料、不释放账单预占、不创建新的审批尝试或第二张企业微信审批单。 +- 对明确提交失败、提交结果未知和已取得企业微信审批单号但本地未确认的情形,分别恢复可靠投递、优先确认原提交结果或查询同步原审批状态。 +- 对仍在正常重试、已进入企业微信审批中或已完成的申请执行幂等处理或明确拒绝,并记录不含敏感材料的恢复审计。 +- 列表与详情返回恢复操作所需的安全提交状态、失败摘要与可恢复标记,使前端只展示服务端判定允许的操作。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `employee-collection-bill`: 增加员工核销申请原审批的人工恢复、权限、幂等、状态展示与审计行为。 + +## Impact + +- 新增后台员工核销申请恢复接口及 RouteSpec/OpenAPI 说明。 +- 影响员工核销申请 Handler、Application 用例、只读投影、通用审批提交恢复接缝、Outbox 事件状态处理与审计。 +- 复用现有 PostgreSQL、Outbox、Asynq 和企业微信审批适配器,不新增第三方依赖或数据库迁移;最近恢复时间使用企业微信审批上下文既有 `last_recovery_at`,人工触发人与动作结果写入审计事实。 diff --git a/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/specs/employee-collection-bill/spec.md b/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/specs/employee-collection-bill/spec.md new file mode 100644 index 00000000..83e4cc89 --- /dev/null +++ b/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/specs/employee-collection-bill/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: 核销申请原审批可受控恢复 +系统 SHALL 提供 `POST /api/admin/employee-collection-applications/{id}/recover-approval`,允许原申请员工、超级管理员和平台用户恢复仍处于审批中的核销申请。恢复对象 MUST 固定为该申请的最新审批尝试与最新审批实例,并校验审批实例业务类型为 `employee_collection_approval`、业务标识等于最新尝试标识且尝试关联的审批实例一致;请求不得接受调用方传入审批实例或新材料。恢复 MUST 复用原审批实例及同一稳定提交事实,不得修改申请材料、账单分摊或审批快照,不得释放账单预占,不得新增审批尝试、审批实例或第二张企业微信审批单。管理员代为恢复时,企业微信发起人 MUST 保持原申请人,且系统 MUST 记录实际操作者。 + +恢复资格 MUST 由服务端根据申请状态、最新审批实例和提交事实判定。明确提交失败且可确认企业微信未受理时,系统 SHALL 恢复同一提交事实;提交结果未知时,系统 MUST 优先确认原提交结果,在无法证明未受理前不得再次提交创建审批;已取得企业微信审批单号但本地状态未确认时,系统 MUST 查询并同步原审批单。仍在正常重试或企业微信已处于审批中的请求 SHALL 幂等返回当前恢复状态,不得重复投递;申请或审批已经进入终态时 MUST 拒绝恢复。 + +核销申请列表与详情 SHALL 返回服务端判定的可恢复标记、审批提交状态及名称、安全失败摘要和最近恢复时间;列表投影 MUST 批量完成,查询次数不得随当页申请数量线性增长。恢复操作及结果 MUST 写入审计,但不得记录支付凭证、完整外部交易流水号、完整企业微信响应或内部事件键。代理、企业账号及其他无权账号的操作与申请不存在 MUST 不可区分。 + +#### Scenario: 明确提交失败后恢复原审批 +- **GIVEN** 核销申请仍处于审批中,最新审批实例的提交明确失败,且系统可确认企业微信未受理 +- **WHEN** 原申请员工或有权管理员请求恢复审批 +- **THEN** 系统恢复同一稳定提交事实,保持原审批实例、申请材料、账单分摊和预占金额不变,且不创建第二张企业微信审批单 + +#### Scenario: 提交结果未知时禁止直接重提 +- **GIVEN** 核销申请仍处于审批中,最新审批实例的提交结果未知 +- **WHEN** 授权账号请求恢复审批 +- **THEN** 系统进入原提交结果确认流程,在无法证明企业微信未受理前不再次调用创建审批,并返回正在确认的恢复状态 + +#### Scenario: 已取得企微审批单号时查询同步 +- **GIVEN** 最新审批实例已有企业微信审批单号,但本地提交或审批状态尚未确认 +- **WHEN** 授权账号请求恢复审批 +- **THEN** 系统查询并同步该原审批单,不恢复创建提交且不生成新的审批实例 + +#### Scenario: 重复或并发恢复保持幂等 +- **WHEN** 员工与管理员重复或并发恢复同一核销申请 +- **THEN** 系统至多一次改变原提交事实的恢复状态,其余请求返回相同的正在恢复或已确认状态,审批实例数、审批尝试数和账单预占金额均不增加 + +#### Scenario: 正常重试中的申请再次恢复 +- **GIVEN** 原审批提交事件仍处于等待重试或处理中 +- **WHEN** 授权账号再次请求恢复 +- **THEN** 系统不重复投递,返回正在恢复及当前提交状态 + +#### Scenario: 审批终态不可恢复 +- **WHEN** 核销申请或其最新企业微信审批已经进入通过、驳回、撤销或关闭终态后请求恢复 +- **THEN** 系统返回状态冲突,不改变申请、账单、审批实例或提交事实 + +#### Scenario: 申请人与管理员权限边界 +- **WHEN** 原申请员工、超级管理员或平台用户请求恢复,或代理、企业账号及其他无权账号请求恢复 +- **THEN** 系统允许前三类账号恢复并在管理员代操作时保持原申请人为企业微信发起人;对无权账号以与申请不存在不可区分的结果拒绝 + +#### Scenario: 配置修复后再次恢复 +- **GIVEN** 原审批因申请人企业微信身份、审批场景或模板映射不可用而恢复失败,且申请仍满足恢复条件 +- **WHEN** 维护者修复配置后授权账号再次请求恢复 +- **THEN** 系统继续恢复同一原审批事实,不要求重建申请或释放账单预占 diff --git a/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/tasks.md b/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/tasks.md new file mode 100644 index 00000000..9cf7f95c --- /dev/null +++ b/openspec/changes/archive/2026-09-21-recover-employee-collection-approval/tasks.md @@ -0,0 +1,27 @@ +## 1. 现有事实与恢复接缝 + +- [x] 1.1 走读通用审批实例、企业微信上下文、提交 Outbox、集成交互记录及核销申请最新尝试字段,形成明确失败、结果未知、已有企微单号、正常重试与终态的判定矩阵,并确认原申请员工、超级管理员和平台用户可恢复,代理与企业账号拒绝。 +- [x] 1.2 在通用审批 Application/Infrastructure 边界新增按原审批实例恢复提交或触发查询确认的接缝;稳定事件键继续使用 `approval:{instance_id}:submission`,活动事件幂等返回,终态失败事件以条件更新恢复投递资格。 +- [x] 1.3 复用企业微信审批上下文既有 `last_recovery_at` 返回最近渠道恢复时间,并以核销恢复审计记录人工触发时间、操作者和动作结果;不新增数据库迁移,不使用通用 `updated_at` 冒充恢复时间。 + +## 2. 员工核销恢复用例 + +- [x] 2.1 在员工核销 Application 中实现恢复原审批用例:锁定申请与最新尝试,校验审批中状态、业务类型为 `employee_collection_approval`、业务标识等于最新尝试、尝试与最新实例引用一致,以及原申请员工或管理员权限;引用不一致、越权与终态申请均拒绝。 +- [x] 2.2 按判定矩阵处理已有企微审批单号、结果未知、明确未受理失败和正常重试;恢复不得修改申请材料、审批快照、分摊或账单预占,不得新增审批尝试或审批实例。 +- [x] 2.3 为每次人工恢复写入脱敏审计,记录申请、审批实例、实际操作者、触发前状态、恢复动作与结果;管理员代操作保持原申请人为企业微信发起人。 +- [x] 2.4 保证员工与管理员重复或并发恢复时至多一次改变提交事实,其余请求返回一致的正在恢复、已确认提交或已在审批中结果。 + +## 3. 查询投影与 HTTP 入口 + +- [x] 3.1 扩展核销申请列表与详情 DTO,返回服务端判定的可恢复标记、提交状态、安全失败摘要、恢复动作结果与最近恢复时间;失败摘要不得包含附件、完整流水、企微原文或内部队列键。 +- [x] 3.2 在核销申请 Query 中按页批量读取最新审批实例、企业微信上下文和提交事件,计算恢复投影,避免列表逐条查询。 +- [x] 3.3 新增 `POST /api/admin/employee-collection-applications/{id}/recover-approval` Handler 与 RouteSpec,只接受路径申请 ID;同步可执行路由、`cmd/api/docs.go`、`cmd/gendocs/main.go` 及 OpenAPI 生成物要求。 +- [x] 3.4 核对原申请员工、超级管理员、平台用户、代理与企业账号的路由门禁;前三类允许恢复,后两类拒绝,越权与不存在保持不可区分,且不扩大列表、详情或其他核销写操作权限。 + +## 4. 验证与规格同步 + +- [x] 4.1 在维护者指定测试环境验证明确失败恢复:同一稳定事件恢复并最终只产生一张企业微信审批单,审批尝试数、审批实例数和账单预占金额不增加。 +- [x] 4.2 验证提交结果未知与已有企微单号两条路径只确认或查询原审批,不再次调用创建审批;正常重试中的重复恢复幂等返回。 +- [x] 4.3 验证员工与管理员重复及并发操作、终态拒绝、本人和管理员权限边界、配置修复后再次恢复,以及恢复失败时申请材料和预占均不变化。 +- [x] 4.4 核对列表与详情恢复投影、审计脱敏和批量查询行为,并运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`。 +- [x] 4.5 同步证据矩阵和主 Spec 可达操作索引,运行 `openspec validate recover-employee-collection-approval --strict`、`openspec doctor --json`、`openspec validate --all` 与 `./scripts/context-health.sh`。 diff --git a/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/.openspec.yaml b/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/.openspec.yaml new file mode 100644 index 00000000..cbd245e4 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-20 diff --git a/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/design.md b/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/design.md new file mode 100644 index 00000000..506099eb --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/design.md @@ -0,0 +1,64 @@ +## Context + +现有设备模型将 `device_type` 保存为普通字符串,设备列表接口会返回该字段,但没有专用的去重选项查询接口。H5 弹窗配置的 `device_types` 按字符串原值匹配,因此选项接口必须复用同一字段事实,不引入独立枚举或转换层。 + +## Goals / Non-Goals + +**Goals:** + +- 新增一个平台管理端只读接口,返回有效设备的去重设备类型。 +- 复用现有管理端鉴权、路由注册、DTO、统一响应和 OpenAPI 文档模式。 +- 将空值过滤、首尾空白清理、去重和稳定排序放在后端,减少前端处理。 +- 让返回值可原样用于 H5 弹窗配置 `device_types`。 + +**Non-Goals:** + +- 不建立设备类型字典表或新增数据库迁移。 +- 不把设备类型改造成固定枚举。 +- 不修改设备创建、导入、编辑流程。 +- 不修改 H5 弹窗匹配逻辑。 +- 不返回设备数量、设备型号、制造商或设备明细。 + +## Decisions + +### 路由与权限 + +建议新增: + +```http +GET /api/admin/devices/types +``` + +路由放在 `/devices` 分组下,并在动态 `/:virtual_no` 路由之前注册,避免静态路径被动态路由吞掉。接口沿用设备管理端的认证链路,并要求平台管理权限;非平台用户沿用现有统一 403 行为。 + +### 响应结构 + +沿用项目统一成功响应包装,业务数据建议为字符串数组: + +```json +{ + "code": 0, + "message": "success", + "data": ["Dongle", "MiFi", "UFi", "充电宝"] +} +``` + +设备类型本身没有独立编码和展示名,因此不包装成 `{value,label}`,避免前后端维护两套语义。前端需要选项时直接将字符串作为 value 和 label。 + +### 数据查询口径 + +查询 `tb_device.device_type`,条件为未删除;使用数据库层 `TRIM`、非空过滤、`DISTINCT` 和升序排序。应用层再保证响应为非空字符串列表。返回值使用去除首尾空白后的字符串,以便与配置写入和候选匹配保持一致。 + +### 分层位置 + +沿用当前设备模块的简单读取路径:Handler → 设备 Service/查询逻辑 → GORM。由于查询只返回单列去重结果,不复用分页设备列表,也不加载设备详情,避免前端遍历分页结果和服务端构造无关对象。 + +### 文档与装配 + +同步新增路由的 OpenAPI 注册,并按仓库约束更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 中的 Handler 文档装配(如现有生成方式需要)。不新增独立 Handler,优先在现有 `DeviceHandler` 中增加方法。 + +## Risks / Trade-offs + +- 设备类型是自由字符串,历史数据中的大小写差异(例如 `MiFi` 与 `MIFI`)会继续作为不同选项返回;本变更不擅自合并,避免改变现有弹窗匹配语义。 +- 返回字符串数组简单且兼容当前 `device_types`,但未来若需要数量、显示名或排序权重,需要另行扩展接口契约。 +- 依赖数据库对 `TRIM`、`DISTINCT` 和排序执行;查询结果规模是设备类型基数而非设备总量,预期远小于分页设备列表。 diff --git a/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/proposal.md b/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/proposal.md new file mode 100644 index 00000000..a4dcb467 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/proposal.md @@ -0,0 +1,28 @@ +## Why + +H5 运营弹窗配置的 `device_types` 需要填写设备表中的实际字符串值,但当前没有专门的设备类型选项接口,前端只能分页读取设备并自行去重,容易遗漏类型且增加无关数据传输。新增平台管理端查询接口,直接返回当前有效设备的去重类型,供弹窗配置等筛选场景使用。 + +## What Changes + +- 新增平台管理端设备类型选项查询接口。 +- 从 `tb_device.device_type` 查询未删除、非空且去除首尾空白后的设备类型。 +- 对设备类型去重并按稳定、明确的顺序返回。 +- 返回值使用设备类型原始业务字符串,不新增固定枚举、不建立设备类型主数据表。 +- 接入现有管理端鉴权、路由注册、OpenAPI 文档与响应规范。 +- 不修改既有设备字段、H5 弹窗 `device_types` 匹配规则或数据库结构。 + +## Capabilities + +### New Capabilities + +- `device-type-options`: 为管理端提供去重设备类型选项查询能力。 + +### Modified Capabilities + +- 无。 + +## Impact + +- 影响管理端 API 路由、设备查询服务/查询层、DTO 与 OpenAPI 文档。 +- 只读数据库查询,无迁移、写入或生产数据变更。 +- 前端可直接将返回的 `value` 原样用于 H5 弹窗配置的 `device_types`。 diff --git a/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/specs/device-type-options/spec.md b/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/specs/device-type-options/spec.md new file mode 100644 index 00000000..673e6794 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/specs/device-type-options/spec.md @@ -0,0 +1,53 @@ +## Purpose + +为管理端提供稳定、可直接用于筛选控件的设备类型选项来源,避免前端分页读取全部设备后自行去重,并确保 H5 弹窗配置提交的值与系统实际设备类型一致。 + +## ADDED Requirements + +### Requirement: 管理端可查询去重设备类型 + +系统 MUST 提供一个受平台管理权限保护的只读接口,返回当前有效设备记录中的设备类型选项。 + +#### Scenario: 返回有效设备的非空去重类型 + +- **WHEN** 具备平台管理权限的调用方请求设备类型选项接口 +- **THEN** 系统仅从未删除设备中读取 `device_type` +- **AND** 忽略空字符串及仅包含空白字符的值 +- **AND** 对去除首尾空白后的设备类型进行去重 +- **AND** 每个设备类型只返回一次 + +#### Scenario: 返回顺序稳定 + +- **WHEN** 查询成功 +- **THEN** 系统按设备类型值的确定性升序返回结果 +- **AND** 相同接口请求在数据未变化时返回相同顺序 + +#### Scenario: 返回值可直接用于弹窗配置 + +- **WHEN** 前端获取设备类型选项 +- **THEN** 每个选项的值 MUST 是系统设备记录中用于匹配的设备类型字符串 +- **AND** 前端可将该值原样放入 H5 弹窗配置的 `device_types` 数组 +- **AND** 接口不得把设备类型转换为设备 ID、设备型号或固定枚举编码 + +### Requirement: 空结果与权限错误语义明确 + +接口 MUST 使用现有管理端接口的统一响应和错误语义。 + +#### Scenario: 当前没有有效设备类型 + +- **WHEN** 当前有效设备没有非空设备类型 +- **THEN** 接口返回成功响应 +- **AND** 设备类型列表为空 +- **AND** 不返回空值占位项 + +#### Scenario: 调用方无平台管理权限 + +- **WHEN** 非超级管理员或平台账号请求接口 +- **THEN** 系统拒绝请求并返回统一的无权限错误 +- **AND** 不泄露设备类型数据 + +#### Scenario: 数据库查询失败 + +- **WHEN** 读取设备类型时发生数据库错误 +- **THEN** 接口返回统一的数据库错误 +- **AND** 不返回部分或伪造的设备类型列表 diff --git a/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/tasks.md b/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/tasks.md new file mode 100644 index 00000000..373c0f20 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-device-type-options-endpoint/tasks.md @@ -0,0 +1,18 @@ +## 1. API Contract and Query + +- [x] 1.1 Add the device-type options response DTO and OpenAPI descriptions. +- [x] 1.2 Implement the distinct non-empty trimmed `device_type` query with deterministic ascending order. +- [x] 1.3 Add the platform-management handler method using the existing unified response and error handling. + +## 2. Route and Documentation Integration + +- [x] 2.1 Register `GET /api/admin/devices/types` before the dynamic device identifier route. +- [x] 2.2 Wire the endpoint into generated API documentation and update required documentation assembly points. +- [x] 2.3 Confirm the endpoint reuses existing platform-management authorization and does not expose data to unauthorized callers. + +## 3. Verification + +- [x] 3.1 Verify successful results contain only unique, non-empty device types in stable ascending order. +- [x] 3.2 Verify an empty source set returns a successful empty list. +- [x] 3.3 Verify unauthorized access and database failures use the existing unified error semantics. +- [x] 3.4 Run the repository-required build, documentation generation, OpenSpec validation, and formatting checks for changed Go files. diff --git a/openspec/specs/device-type-options/spec.md b/openspec/specs/device-type-options/spec.md new file mode 100644 index 00000000..cce02016 --- /dev/null +++ b/openspec/specs/device-type-options/spec.md @@ -0,0 +1,55 @@ +# 设备类型选项当前行为 + +## Purpose + +为管理端提供稳定、可直接用于筛选控件的设备类型选项来源,避免前端分页读取全部设备后自行去重,并确保 H5 弹窗配置提交的值与系统实际设备类型一致。 + +## Requirements + +### Requirement: 管理端可查询去重设备类型 + +系统 MUST 提供一个受平台管理权限保护的只读接口,返回当前有效设备记录中的设备类型选项。 + +#### Scenario: 返回有效设备的非空去重类型 + +- **WHEN** 具备平台管理权限的调用方请求设备类型选项接口 +- **THEN** 系统仅从未删除设备中读取 `device_type` +- **AND** 忽略空字符串及仅包含空白字符的值 +- **AND** 对去除首尾空白后的设备类型进行去重 +- **AND** 每个设备类型只返回一次 + +#### Scenario: 返回顺序稳定 + +- **WHEN** 查询成功 +- **THEN** 系统按设备类型值的确定性升序返回结果 +- **AND** 相同接口请求在数据未变化时返回相同顺序 + +#### Scenario: 返回值可直接用于弹窗配置 + +- **WHEN** 前端获取设备类型选项 +- **THEN** 每个选项的值 MUST 是系统设备记录中用于匹配的设备类型字符串 +- **AND** 前端可将该值原样放入 H5 弹窗配置的 `device_types` 数组 +- **AND** 接口不得把设备类型转换为设备 ID、设备型号或固定枚举编码 + +### Requirement: 空结果与权限错误语义明确 + +接口 MUST 使用现有管理端接口的统一响应和错误语义。 + +#### Scenario: 当前没有有效设备类型 + +- **WHEN** 当前有效设备没有非空设备类型 +- **THEN** 接口返回成功响应 +- **AND** 设备类型列表为空 +- **AND** 不返回空值占位项 + +#### Scenario: 调用方无平台管理权限 + +- **WHEN** 非超级管理员或平台账号请求接口 +- **THEN** 系统拒绝请求并返回统一的无权限错误 +- **AND** 不泄露设备类型数据 + +#### Scenario: 数据库查询失败 + +- **WHEN** 读取设备类型时发生数据库错误 +- **THEN** 接口返回统一的数据库错误 +- **AND** 不返回部分或伪造的设备类型列表 diff --git a/openspec/specs/employee-collection-bill/spec.md b/openspec/specs/employee-collection-bill/spec.md index a1da2708..18bf54cc 100644 --- a/openspec/specs/employee-collection-bill/spec.md +++ b/openspec/specs/employee-collection-bill/spec.md @@ -209,4 +209,4 @@ ### 核销申请 -`POST /api/admin/employee-collection-applications`(创建核销申请);`GET /api/admin/employee-collection-applications`(查询核销申请);`GET /api/admin/employee-collection-applications/{id}`(查询核销申请详情);`PUT /api/admin/employee-collection-applications/{id}`(修改并重提核销申请)。 +`POST /api/admin/employee-collection-applications`(创建核销申请);`GET /api/admin/employee-collection-applications`(查询核销申请);`GET /api/admin/employee-collection-applications/{id}`(查询核销申请详情);`PUT /api/admin/employee-collection-applications/{id}`(修改并重提核销申请);`POST /api/admin/employee-collection-applications/{id}/recover-approval`(恢复原审批提交)。 diff --git a/pkg/constants/wecom.go b/pkg/constants/wecom.go index 3b0ec654..ce436377 100644 --- a/pkg/constants/wecom.go +++ b/pkg/constants/wecom.go @@ -84,3 +84,21 @@ const ( // WeComSubmissionStatusUnknown 表示请求可能已到达企微但结果未知。 WeComSubmissionStatusUnknown = 4 ) + +// GetWeComSubmissionStatusName 返回企业微信提交状态中文名称。 +func GetWeComSubmissionStatusName(status int) string { + switch status { + case WeComSubmissionStatusReady: + return "待提交" + case WeComSubmissionStatusSending: + return "发送中" + case WeComSubmissionStatusSubmitted: + return "已提交" + case WeComSubmissionStatusFailed: + return "提交失败" + case WeComSubmissionStatusUnknown: + return "结果未知" + default: + return "未知" + } +} diff --git a/pkg/openapi/generator.go b/pkg/openapi/generator.go index cbc705f3..99581a8c 100644 --- a/pkg/openapi/generator.go +++ b/pkg/openapi/generator.go @@ -378,9 +378,7 @@ func (g *Generator) setEnvelopeResponse(op *openapi3.Operation, output interface panic(err) } - // 获取生成的 DTO schema dtoSchemaOrRef := tempOp.Responses.MapOfResponseOrRefValues[strconv.Itoa(statusCode)].Response.Content["application/json"].Schema - objectType := openapi3.SchemaType("object") integerType := openapi3.SchemaType("integer") stringType := openapi3.SchemaType("string") @@ -544,8 +542,15 @@ func (g *Generator) Save(filename string) error { return err } + if schemas := g.Reflector.Spec.Components; schemas != nil && schemas.Schemas != nil { + if schema, ok := schemas.Schemas.MapOfSchemaOrRefValues["DtoDeviceTypeOptionsResponse"]; ok && schema.Schema != nil { + nullable := false + schema.Schema.Nullable = &nullable + schemas.Schemas.MapOfSchemaOrRefValues["DtoDeviceTypeOptionsResponse"] = schema + } + } + // 安全的方法:MarshalJSON -> Unmarshal -> MarshalYAML - // 这确保了我们遵守 openapi3 库中定义的 `json` 标签 jsonBytes, err := g.Reflector.Spec.MarshalJSON() if err != nil { return err