固化七月迭代审计治理进展以隔离线上热修
Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。 Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。 Confidence: medium Scope-risk: broad Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。 Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。 Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
This commit is contained in:
223
internal/application/accessaudit/change.go
Normal file
223
internal/application/accessaudit/change.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Package accessaudit 定义账号权限与组织简单写用例的统一审计接缝。
|
||||
package accessaudit
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ChangeAudit 是账号权限与组织变更交给统一审计 Port 的事实。
|
||||
type ChangeAudit struct {
|
||||
ActionCode string
|
||||
Summary string
|
||||
Result string
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
OperatorID uint
|
||||
ActorKind string
|
||||
ActorName string
|
||||
Source string
|
||||
ScopeType string
|
||||
Account *model.Account
|
||||
Accounts []AccountChange
|
||||
Shop *model.Shop
|
||||
ParentShop *model.Shop
|
||||
Enterprise *model.Enterprise
|
||||
Cards []IotCardChange
|
||||
CardAuthorizations []EnterpriseCardAuthorizationChange
|
||||
Devices []DeviceChange
|
||||
DeviceBindings []DeviceSimBindingChange
|
||||
DeviceAuthorizations []EnterpriseDeviceAuthorizationChange
|
||||
PersonalCustomer *model.PersonalCustomer
|
||||
PersonalPhones []PersonalCustomerPhoneChange
|
||||
PersonalOpenIDs []PersonalCustomerOpenIDChange
|
||||
Role *model.Role
|
||||
Roles []RoleChange
|
||||
Permissions []PermissionChange
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
SubjectVisibility string
|
||||
SubjectSummary string
|
||||
SubjectData map[string]any
|
||||
}
|
||||
|
||||
// PersonalCustomerPhoneChange 保存个人客户手机号资源变化。
|
||||
type PersonalCustomerPhoneChange struct {
|
||||
Phone *model.PersonalCustomerPhone
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// PersonalCustomerOpenIDChange 保存个人客户微信主体资源变化。
|
||||
type PersonalCustomerOpenIDChange struct {
|
||||
OpenID *model.PersonalCustomerOpenID
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// DeviceChange 保存组织操作关联设备的资源变化与主体安全投影。
|
||||
type DeviceChange struct {
|
||||
Device *model.Device
|
||||
Relation string
|
||||
Role string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
SubjectVisibility string
|
||||
SubjectSummary string
|
||||
SubjectData map[string]any
|
||||
}
|
||||
|
||||
// DeviceSimBindingChange 保存企业设备授权涉及的卡槽绑定快照。
|
||||
type DeviceSimBindingChange struct {
|
||||
Binding *model.DeviceSimBinding
|
||||
Relation string
|
||||
Role string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// EnterpriseDeviceAuthorizationChange 保存企业设备授权记录的直接变化。
|
||||
type EnterpriseDeviceAuthorizationChange struct {
|
||||
Authorization *model.EnterpriseDeviceAuthorization
|
||||
Relation string
|
||||
Role string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// IotCardChange 保存组织操作关联卡的资源变化与主体安全投影。
|
||||
type IotCardChange struct {
|
||||
Card *model.IotCard
|
||||
Relation string
|
||||
Role string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
SubjectVisibility string
|
||||
SubjectSummary string
|
||||
SubjectData map[string]any
|
||||
}
|
||||
|
||||
// EnterpriseCardAuthorizationChange 保存企业卡授权记录的直接变化。
|
||||
type EnterpriseCardAuthorizationChange struct {
|
||||
Authorization *model.EnterpriseCardAuthorization
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// AccountChange 保存店铺操作关联账号的资源角色与直接变化。
|
||||
type AccountChange struct {
|
||||
Account *model.Account
|
||||
Relation string
|
||||
Role string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// RoleChange 保存主体授权中单个角色资源的前后变化。
|
||||
type RoleChange struct {
|
||||
Role *model.Role
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// PermissionChange 保存单个权限资源的前后变化。
|
||||
type PermissionChange struct {
|
||||
Permission *model.Permission
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// Writer 接收账号权限与组织事务内审计事实。
|
||||
type Writer interface {
|
||||
WriteAccessChange(context.Context, *gorm.DB, ChangeAudit) error
|
||||
}
|
||||
|
||||
// RecordFailure 在业务回滚后使用独立短事务记录失败或拒绝事实。
|
||||
func RecordFailure(ctx context.Context, db *gorm.DB, writer Writer, change ChangeAudit, originalErr error) {
|
||||
fillFailure(changeError(originalErr), &change)
|
||||
if db == nil || writer == nil {
|
||||
recordSecondaryFailure(ctx, change, apperrors.New(apperrors.CodeInvalidStatus, "统一组织审计接缝未配置"))
|
||||
return
|
||||
}
|
||||
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return writer.WriteAccessChange(ctx, tx, change)
|
||||
}); err != nil {
|
||||
recordSecondaryFailure(ctx, change, err)
|
||||
}
|
||||
}
|
||||
|
||||
func changeError(err error) *apperrors.AppError {
|
||||
var appErr *apperrors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return appErr
|
||||
}
|
||||
return apperrors.New(apperrors.CodeInternalError, "账号权限或组织操作失败")
|
||||
}
|
||||
|
||||
func fillFailure(appErr *apperrors.AppError, change *ChangeAudit) {
|
||||
if change.Result == "" {
|
||||
change.Result = constants.AuditResultFailed
|
||||
}
|
||||
if change.ErrorCode == "" {
|
||||
change.ErrorCode = strconv.Itoa(appErr.Code)
|
||||
}
|
||||
if change.ErrorSummary == "" {
|
||||
change.ErrorSummary = appErr.Message
|
||||
}
|
||||
}
|
||||
|
||||
func recordSecondaryFailure(ctx context.Context, change ChangeAudit, err error) {
|
||||
value := auditcontext.From(ctx)
|
||||
auditfailure.RecordSecondaryWriteFailure(
|
||||
change.ActionCode, resourceKey(change), value.RequestID, value.CorrelationID, change.ErrorCode, err,
|
||||
)
|
||||
}
|
||||
|
||||
func resourceKey(change ChangeAudit) string {
|
||||
if change.Account != nil {
|
||||
if change.Account.ID != 0 {
|
||||
return strconv.FormatUint(uint64(change.Account.ID), 10)
|
||||
}
|
||||
return change.Account.Username
|
||||
}
|
||||
if change.Enterprise != nil {
|
||||
if change.Enterprise.ID != 0 {
|
||||
return strconv.FormatUint(uint64(change.Enterprise.ID), 10)
|
||||
}
|
||||
return change.Enterprise.EnterpriseCode
|
||||
}
|
||||
if change.PersonalCustomer != nil {
|
||||
return strconv.FormatUint(uint64(change.PersonalCustomer.ID), 10)
|
||||
}
|
||||
if change.Shop != nil {
|
||||
if change.Shop.ID != 0 {
|
||||
return strconv.FormatUint(uint64(change.Shop.ID), 10)
|
||||
}
|
||||
return change.Shop.ShopCode
|
||||
}
|
||||
if change.Role != nil {
|
||||
if change.Role.ID != 0 {
|
||||
return strconv.FormatUint(uint64(change.Role.ID), 10)
|
||||
}
|
||||
return change.Role.RoleName
|
||||
}
|
||||
for _, permission := range change.Permissions {
|
||||
if permission.Permission == nil {
|
||||
continue
|
||||
}
|
||||
if permission.Permission.ID != 0 {
|
||||
return strconv.FormatUint(uint64(permission.Permission.ID), 10)
|
||||
}
|
||||
return permission.Permission.PermCode
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
46
internal/application/accountaudit/lifecycle.go
Normal file
46
internal/application/accountaudit/lifecycle.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Package accountaudit 定义账号生命周期写入统一审计的应用边界。
|
||||
package accountaudit
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LifecycleAudit 是账号生命周期用例提交给统一 Writer 的业务事实。
|
||||
type LifecycleAudit struct {
|
||||
ActionCode string
|
||||
Summary string
|
||||
Result string
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
Account *model.Account
|
||||
Shop *model.Shop
|
||||
Enterprise *model.Enterprise
|
||||
Roles []*model.Role
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// SecurityAudit 是账号安全用例提交给统一 Writer 的无凭据业务事实。
|
||||
type SecurityAudit struct {
|
||||
ActionCode string
|
||||
Summary string
|
||||
Result string
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
ActorID uint
|
||||
ActorName string
|
||||
Account *model.Account
|
||||
AuthenticationKey string
|
||||
Authentication map[string]any
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// Writer 在调用方提供的事务内追加账号生命周期事件。
|
||||
type Writer interface {
|
||||
WriteAccountLifecycle(ctx context.Context, tx *gorm.DB, audit LifecycleAudit) error
|
||||
WriteAccountSecurity(ctx context.Context, tx *gorm.DB, audit SecurityAudit) error
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type ConfirmOnlinePaymentCommand struct {
|
||||
PaidAt time.Time
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
ParentEventID string
|
||||
}
|
||||
|
||||
// PaymentConfirmedEvent 是第三方收款事实提交后的代理充值入账事件。
|
||||
@@ -44,6 +45,7 @@ type PaymentConfirmedEvent struct {
|
||||
PaidAt time.Time `json:"paid_at"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
ParentEventID string `json:"parent_event_id,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentConfirmedEventWriter 在支付确认事务内追加可靠入账事件。
|
||||
@@ -54,6 +56,7 @@ type PaymentConfirmedEventWriter interface {
|
||||
// ConfirmOnlinePaymentResult 返回支付确认是否属于幂等重放。
|
||||
type ConfirmOnlinePaymentResult struct {
|
||||
RechargeID uint
|
||||
PaymentID uint
|
||||
AlreadyConfirmed bool
|
||||
}
|
||||
|
||||
@@ -92,6 +95,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
|
||||
return err
|
||||
}
|
||||
result.RechargeID = recharge.ID
|
||||
result.PaymentID = payment.ID
|
||||
if alreadyConfirmed {
|
||||
result.AlreadyConfirmed = true
|
||||
return nil
|
||||
@@ -124,6 +128,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
|
||||
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, UserID: recharge.UserID, Amount: recharge.Amount,
|
||||
PaymentMethod: command.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
|
||||
PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
ParentEventID: command.ParentEventID,
|
||||
}
|
||||
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入代理充值支付确认事件失败")
|
||||
|
||||
@@ -4,6 +4,7 @@ package outbox
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
@@ -33,6 +35,30 @@ type RecoveryAudit struct {
|
||||
BatchID string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
Events []RecoveryEventAudit
|
||||
Result string
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
}
|
||||
|
||||
// RecoveryEventAudit 是一次人工恢复中单个 Outbox 事件的身份和状态变化。
|
||||
type RecoveryEventAudit struct {
|
||||
ID uint
|
||||
EventID string
|
||||
EventType string
|
||||
AggregateType string
|
||||
AggregateID string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
BusinessKey string
|
||||
BeforeStatus int
|
||||
AfterStatus int
|
||||
BeforeNextAttempt time.Time
|
||||
AfterNextAttempt time.Time
|
||||
BeforeLeaseOwner *string
|
||||
BeforeLeaseExpires *time.Time
|
||||
AfterLeaseOwner *string
|
||||
AfterLeaseExpires *time.Time
|
||||
}
|
||||
|
||||
// AuditWriter 是 tech-global-audit 提供实现的统一审计接缝。
|
||||
@@ -65,23 +91,38 @@ func (s *RecoveryService) Replay(ctx context.Context, operator Operator, ids []u
|
||||
}
|
||||
batchID := uuid.NewString()
|
||||
now := s.now().UTC()
|
||||
failureAudit := RecoveryAudit{
|
||||
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReplay,
|
||||
Description: "人工重放 Outbox 事件失败", Reason: reason, BatchID: batchID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
|
||||
}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
events, err := loadSelectedForUpdate(tx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
failureAudit.EventIDs, failureAudit.Events = unchangedRecoveryAudit(events)
|
||||
if len(events) != len(ids) {
|
||||
failureAudit.Events = nil
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的事件不存在或状态不允许重放")
|
||||
}
|
||||
eventIDs := make([]string, 0, len(events))
|
||||
auditEvents := make([]RecoveryEventAudit, 0, len(events))
|
||||
for _, event := range events {
|
||||
allowed := event.Status == constants.OutboxStatusFailed ||
|
||||
(event.Status == constants.OutboxStatusDelivering && event.LeaseExpiresAt != nil && !event.LeaseExpiresAt.After(now))
|
||||
if !allowed {
|
||||
failureAudit.Result = constants.AuditResultDenied
|
||||
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeInvalidStatus)
|
||||
failureAudit.ErrorSummary = "选择的事件不存在或状态不允许重放"
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的事件不存在或状态不允许重放")
|
||||
}
|
||||
eventIDs = append(eventIDs, event.EventID)
|
||||
auditEvents = append(auditEvents, recoveryEventAudit(event, now))
|
||||
}
|
||||
failureAudit.Result = constants.AuditResultFailed
|
||||
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeDatabaseError)
|
||||
failureAudit.ErrorSummary = "Outbox 人工重放事务已回滚"
|
||||
result := tx.Model(&model.OutboxEvent{}).Where("id IN ?", ids).Updates(map[string]any{
|
||||
"status": constants.OutboxStatusPending, "next_attempt_at": now,
|
||||
"lease_owner": nil, "lease_expires_at": nil, "updated_at": now,
|
||||
@@ -90,11 +131,14 @@ func (s *RecoveryService) Replay(ctx context.Context, operator Operator, ids []u
|
||||
return result.Error
|
||||
}
|
||||
return s.audit.WriteRecovery(ctx, tx, RecoveryAudit{
|
||||
OperatorID: operator.ID, OperationType: "outbox_replay", Description: "人工重放 Outbox 事件",
|
||||
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReplay, Description: "人工重放 Outbox 事件",
|
||||
EventIDs: eventIDs, Reason: reason, BatchID: batchID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID, Events: auditEvents,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, failureAudit)
|
||||
}
|
||||
return batchID, err
|
||||
}
|
||||
|
||||
@@ -105,21 +149,36 @@ func (s *RecoveryService) ReleaseExpiredLeases(ctx context.Context, operator Ope
|
||||
}
|
||||
batchID := uuid.NewString()
|
||||
now := s.now().UTC()
|
||||
failureAudit := RecoveryAudit{
|
||||
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReleaseExpiredLease,
|
||||
Description: "人工释放 Outbox 过期租约失败", Reason: reason, BatchID: batchID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
|
||||
}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
events, err := loadSelectedForUpdate(tx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
failureAudit.EventIDs, failureAudit.Events = unchangedRecoveryAudit(events)
|
||||
if len(events) != len(ids) {
|
||||
failureAudit.Events = nil
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的租约不存在或仍然有效")
|
||||
}
|
||||
eventIDs := make([]string, 0, len(events))
|
||||
auditEvents := make([]RecoveryEventAudit, 0, len(events))
|
||||
for _, event := range events {
|
||||
if event.Status != constants.OutboxStatusDelivering || event.LeaseExpiresAt == nil || event.LeaseExpiresAt.After(now) {
|
||||
failureAudit.Result = constants.AuditResultDenied
|
||||
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeInvalidStatus)
|
||||
failureAudit.ErrorSummary = "选择的租约不存在或仍然有效"
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的租约不存在或仍然有效")
|
||||
}
|
||||
eventIDs = append(eventIDs, event.EventID)
|
||||
auditEvents = append(auditEvents, recoveryEventAudit(event, now))
|
||||
}
|
||||
failureAudit.Result = constants.AuditResultFailed
|
||||
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeDatabaseError)
|
||||
failureAudit.ErrorSummary = "Outbox 过期租约释放事务已回滚"
|
||||
result := tx.Model(&model.OutboxEvent{}).Where("id IN ? AND status = ? AND lease_expires_at <= ?", ids, constants.OutboxStatusDelivering, now).
|
||||
Updates(map[string]any{
|
||||
"status": constants.OutboxStatusPending, "next_attempt_at": now,
|
||||
@@ -129,14 +188,31 @@ func (s *RecoveryService) ReleaseExpiredLeases(ctx context.Context, operator Ope
|
||||
return result.Error
|
||||
}
|
||||
return s.audit.WriteRecovery(ctx, tx, RecoveryAudit{
|
||||
OperatorID: operator.ID, OperationType: "outbox_release_expired_lease", Description: "人工释放 Outbox 过期租约",
|
||||
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReleaseExpiredLease, Description: "人工释放 Outbox 过期租约",
|
||||
EventIDs: eventIDs, Reason: reason, BatchID: batchID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID, Events: auditEvents,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, failureAudit)
|
||||
}
|
||||
return batchID, err
|
||||
}
|
||||
|
||||
func (s *RecoveryService) recordFailure(ctx context.Context, audit RecoveryAudit) {
|
||||
if len(audit.Events) == 0 || audit.Result == "" {
|
||||
return
|
||||
}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.audit.WriteRecovery(ctx, tx, audit)
|
||||
})
|
||||
if err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(
|
||||
audit.OperationType, audit.Events[0].EventID, audit.RequestID, audit.CorrelationID, audit.ErrorCode, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func validateCommand(operator Operator, ids []uint, reason string) error {
|
||||
if !operator.SuperAdmin {
|
||||
return pkgerrors.New(pkgerrors.CodeForbidden)
|
||||
@@ -152,3 +228,28 @@ func loadSelectedForUpdate(tx *gorm.DB, ids []uint) ([]model.OutboxEvent, error)
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Order("id ASC").Find(&events).Error
|
||||
return events, err
|
||||
}
|
||||
|
||||
func recoveryEventAudit(event model.OutboxEvent, nextAttempt time.Time) RecoveryEventAudit {
|
||||
return RecoveryEventAudit{
|
||||
ID: event.ID, EventID: event.EventID, EventType: event.EventType,
|
||||
AggregateType: event.AggregateType, AggregateID: event.AggregateID,
|
||||
ResourceType: event.ResourceType, ResourceID: event.ResourceID, BusinessKey: event.BusinessKey,
|
||||
BeforeStatus: event.Status, AfterStatus: constants.OutboxStatusPending,
|
||||
BeforeNextAttempt: event.NextAttemptAt, AfterNextAttempt: nextAttempt,
|
||||
BeforeLeaseOwner: event.LeaseOwner, BeforeLeaseExpires: event.LeaseExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
func unchangedRecoveryAudit(events []model.OutboxEvent) ([]string, []RecoveryEventAudit) {
|
||||
eventIDs := make([]string, 0, len(events))
|
||||
auditEvents := make([]RecoveryEventAudit, 0, len(events))
|
||||
for _, event := range events {
|
||||
eventIDs = append(eventIDs, event.EventID)
|
||||
auditEvent := recoveryEventAudit(event, event.NextAttemptAt)
|
||||
auditEvent.AfterStatus = event.Status
|
||||
auditEvent.AfterLeaseOwner = event.LeaseOwner
|
||||
auditEvent.AfterLeaseExpires = event.LeaseExpiresAt
|
||||
auditEvents = append(auditEvents, auditEvent)
|
||||
}
|
||||
return eventIDs, auditEvents
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package role
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdErrors "errors"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -20,11 +22,12 @@ type PermissionChecker interface {
|
||||
type DefaultCreditService struct {
|
||||
db *gorm.DB
|
||||
permissionChecker PermissionChecker
|
||||
accessAudit accessauditapp.Writer
|
||||
}
|
||||
|
||||
// NewDefaultCreditService 创建角色默认信用模板服务。
|
||||
func NewDefaultCreditService(db *gorm.DB, permissionChecker PermissionChecker) *DefaultCreditService {
|
||||
return &DefaultCreditService{db: db, permissionChecker: permissionChecker}
|
||||
func NewDefaultCreditService(db *gorm.DB, permissionChecker PermissionChecker, accessAudit accessauditapp.Writer) *DefaultCreditService {
|
||||
return &DefaultCreditService{db: db, permissionChecker: permissionChecker, accessAudit: accessAudit}
|
||||
}
|
||||
|
||||
// Update 更新模板;该操作不扫描或修改任何既有钱包。
|
||||
@@ -39,8 +42,8 @@ func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled
|
||||
if err := validateDefaultCredit(enabled, limit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var role model.Role
|
||||
var beforeData map[string]any
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses().First(&role, roleID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -48,6 +51,7 @@ func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "读取角色失败")
|
||||
}
|
||||
beforeData = defaultCreditAuditData(&role)
|
||||
if role.RoleType != constants.RoleTypeCustomer {
|
||||
return errors.New(errors.CodeInvalidParam, "只有客户角色可以配置新建代理默认信用")
|
||||
}
|
||||
@@ -68,14 +72,46 @@ func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled
|
||||
role.DefaultCreditEnabled = enabled
|
||||
role.DefaultCreditLimit = limit
|
||||
role.Updater = operatorID
|
||||
return nil
|
||||
if s.accessAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "角色默认信用审计接缝未配置")
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionRoleDefaultCreditUpdated, Summary: "更新角色默认信用模板", Result: constants.AuditResultSuccess,
|
||||
OperatorID: operatorID, Role: &role, BeforeData: beforeData, AfterData: defaultCreditAuditData(&role),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
if role.ID != 0 {
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionRoleDefaultCreditUpdated, Summary: "更新角色默认信用模板失败",
|
||||
Result: defaultCreditAuditResult(err), OperatorID: operatorID, Role: &role, BeforeData: beforeData,
|
||||
}, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
func defaultCreditAuditData(role *model.Role) map[string]any {
|
||||
return map[string]any{
|
||||
"role_name": role.RoleName, "role_type": role.RoleType,
|
||||
"default_credit_enabled": role.DefaultCreditEnabled, "default_credit_limit": role.DefaultCreditLimit,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultCreditAuditResult(err error) string {
|
||||
var appErr *errors.AppError
|
||||
if !stdErrors.As(err, &appErr) {
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
switch appErr.Code {
|
||||
case errors.CodeInternalError, errors.CodeDatabaseError, errors.CodeRedisError:
|
||||
return constants.AuditResultFailed
|
||||
default:
|
||||
return constants.AuditResultDenied
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DefaultCreditService) authorize(ctx context.Context, operatorID uint) error {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType == constants.UserTypeSuperAdmin {
|
||||
|
||||
@@ -3,11 +3,13 @@ package shop
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -17,12 +19,13 @@ import (
|
||||
|
||||
// CreateService 收口平台与代理创建店铺的完整事务。
|
||||
type CreateService struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
audit accessauditapp.Writer
|
||||
}
|
||||
|
||||
// NewCreateService 创建店铺创建事务脚本。
|
||||
func NewCreateService(db *gorm.DB) *CreateService {
|
||||
return &CreateService{db: db}
|
||||
func NewCreateService(db *gorm.DB, audit accessauditapp.Writer) *CreateService {
|
||||
return &CreateService{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// Create 按操作者类型执行平台显式归属或代理安全继承。
|
||||
@@ -37,26 +40,26 @@ func (s *CreateService) Create(ctx context.Context, request *dto.CreateShopReque
|
||||
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
|
||||
case constants.UserTypeAgent:
|
||||
if request.BusinessOwnerAccountIDSet {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员")
|
||||
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限设置店铺业务员"))
|
||||
}
|
||||
if request.ParentID == nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
|
||||
}
|
||||
if err := middleware.CanManageShop(ctx, *request.ParentID); err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
|
||||
}
|
||||
resolver = resolveInheritedBusinessOwner
|
||||
default:
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
|
||||
}
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.InitPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
|
||||
return s.fail(ctx, request, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败"))
|
||||
}
|
||||
|
||||
var response *dto.ShopResponse
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
created, createErr := createShop(tx, request, operatorID, string(hashedPassword), resolver)
|
||||
created, createErr := createShop(ctx, tx, request, operatorID, string(hashedPassword), resolver, s.audit)
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
@@ -64,14 +67,14 @@ func (s *CreateService) Create(ctx context.Context, request *dto.CreateShopReque
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return s.fail(ctx, request, err)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type businessOwnerResolver func(*gorm.DB, *dto.CreateShopRequest, *model.Shop) (*uint, error)
|
||||
|
||||
func createShop(tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, hashedPassword string, resolveOwner businessOwnerResolver) (*dto.ShopResponse, error) {
|
||||
func createShop(ctx context.Context, tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, hashedPassword string, resolveOwner businessOwnerResolver, audit accessauditapp.Writer) (*dto.ShopResponse, error) {
|
||||
if exists, err := recordExists(tx, &model.Shop{}, "shop_code = ?", request.ShopCode); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验店铺编号失败")
|
||||
} else if exists {
|
||||
@@ -157,9 +160,77 @@ func createShop(tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, ha
|
||||
if err := fillBusinessOwnerResponse(tx, shop, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if audit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "店铺创建审计接缝未配置")
|
||||
}
|
||||
if err := audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopCreated, Summary: "创建店铺",
|
||||
OperatorID: operatorID, Shop: shop, ParentShop: parent,
|
||||
AfterData: shopCreationData(shop),
|
||||
}); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "写入店铺创建审计失败")
|
||||
}
|
||||
if ownerID != nil {
|
||||
if err := audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopBusinessOwnerUpdated, Summary: "设置店铺业务员归属",
|
||||
OperatorID: operatorID, Shop: shop, ParentShop: parent,
|
||||
Accounts: businessOwnerAuditAccounts(tx, nil, ownerID),
|
||||
AfterData: map[string]any{"business_owner_account_id": ownerID},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺业务员归属已设置",
|
||||
}); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "写入店铺业务员审计失败")
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *CreateService) fail(ctx context.Context, request *dto.CreateShopRequest, originalErr error) (*dto.ShopResponse, error) {
|
||||
shop := &model.Shop{ShopName: request.ShopName, ShopCode: request.ShopCode, ParentID: request.ParentID}
|
||||
var parent *model.Shop
|
||||
if request.ParentID != nil {
|
||||
parent = &model.Shop{}
|
||||
parent.ID = *request.ParentID
|
||||
}
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopCreated, Summary: "创建店铺失败", Result: shopAuditFailureResult(originalErr),
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), Shop: shop, ParentShop: parent,
|
||||
}, originalErr)
|
||||
return nil, originalErr
|
||||
}
|
||||
|
||||
func shopCreationData(shop *model.Shop) map[string]any {
|
||||
data := shopProfileData(shop)
|
||||
data["shop_code"] = shop.ShopCode
|
||||
data["parent_id"] = shop.ParentID
|
||||
data["level"] = shop.Level
|
||||
return data
|
||||
}
|
||||
|
||||
func shopProfileData(shop *model.Shop) map[string]any {
|
||||
return map[string]any{
|
||||
"shop_name": shop.ShopName, "contact_name": shop.ContactName, "contact_phone": shop.ContactPhone,
|
||||
"province": shop.Province, "city": shop.City, "district": shop.District, "address": shop.Address,
|
||||
}
|
||||
}
|
||||
|
||||
func shopProfileChanged(before, after *model.Shop) bool {
|
||||
return before.ShopName != after.ShopName || before.ContactName != after.ContactName ||
|
||||
before.ContactPhone != after.ContactPhone || before.Province != after.Province || before.City != after.City ||
|
||||
before.District != after.District || before.Address != after.Address
|
||||
}
|
||||
|
||||
func shopAuditFailureResult(err error) string {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
switch appErr.Code {
|
||||
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeInvalidParentID,
|
||||
errors.CodeShopLevelExceeded, errors.CodeShopCodeExists, errors.CodeUsernameExists, errors.CodePhoneExists:
|
||||
return constants.AuditResultDenied
|
||||
}
|
||||
}
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
|
||||
func resolveParent(tx *gorm.DB, parentID *uint) (*model.Shop, int, error) {
|
||||
if parentID == nil {
|
||||
return nil, 1, nil
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -15,12 +16,13 @@ import (
|
||||
|
||||
// UpdateService 收口店铺资料与业务员归属的简单写事务脚本。
|
||||
type UpdateService struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
audit accessauditapp.Writer
|
||||
}
|
||||
|
||||
// NewUpdateService 创建店铺更新事务脚本。
|
||||
func NewUpdateService(db *gorm.DB) *UpdateService {
|
||||
return &UpdateService{db: db}
|
||||
func NewUpdateService(db *gorm.DB, audit accessauditapp.Writer) *UpdateService {
|
||||
return &UpdateService{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// Update 更新单个店铺;业务员归属变化不会传播到其他店铺。
|
||||
@@ -50,11 +52,16 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
var response *dto.ShopResponse
|
||||
var beforeShop *model.Shop
|
||||
var parentShop *model.Shop
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var shop model.Shop
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shop, shopID).Error; err != nil {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
before := shop
|
||||
beforeShop = &before
|
||||
parentShop = loadAuditParentShop(tx, shop.ParentID)
|
||||
if request.BusinessOwnerAccountIDSet {
|
||||
ownerID, err := validateUpdatedBusinessOwner(tx, request.BusinessOwnerAccountID)
|
||||
if err != nil {
|
||||
@@ -88,14 +95,160 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
|
||||
if err := fillBusinessOwnerResponse(tx, &shop, response); err != nil {
|
||||
return err
|
||||
}
|
||||
if shopProfileChanged(&before, &shop) {
|
||||
if s.audit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "店铺更新审计接缝未配置")
|
||||
}
|
||||
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopUpdated, Summary: "更新店铺基础资料",
|
||||
OperatorID: operatorID, Shop: &shop, ParentShop: parentShop,
|
||||
BeforeData: shopProfileData(&before), AfterData: shopProfileData(&shop),
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺更新审计失败")
|
||||
}
|
||||
}
|
||||
if err := s.writeStateAudits(ctx, tx, &before, &shop, parentShop, operatorID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if beforeShop != nil && requestedShopProfileChanged(beforeShop, request) {
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopUpdated, Summary: "更新店铺基础资料失败", Result: shopAuditFailureResult(err),
|
||||
OperatorID: operatorID, Shop: beforeShop, ParentShop: parentShop,
|
||||
}, err)
|
||||
}
|
||||
s.recordStateFailures(ctx, beforeShop, parentShop, request, operatorID, err)
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *UpdateService) writeStateAudits(ctx context.Context, tx *gorm.DB, before, after, parent *model.Shop, operatorID uint) error {
|
||||
if s.audit == nil && (before.Status != after.Status || !sameOptionalUint(before.BusinessOwnerAccountID, after.BusinessOwnerAccountID) || before.ClientLoginDisabled != after.ClientLoginDisabled) {
|
||||
return errors.New(errors.CodeInvalidStatus, "店铺状态审计接缝未配置")
|
||||
}
|
||||
if before.Status != after.Status {
|
||||
action, summary, subject := constants.AuditActionShopDisabled, "禁用店铺", "店铺已禁用"
|
||||
if after.Status == constants.StatusEnabled {
|
||||
action, summary, subject = constants.AuditActionShopEnabled, "启用店铺", "店铺已启用"
|
||||
}
|
||||
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: action, Summary: summary, OperatorID: operatorID, Shop: after, ParentShop: parent,
|
||||
BeforeData: map[string]any{"status": before.Status}, AfterData: map[string]any{"status": after.Status},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subject,
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺状态审计失败")
|
||||
}
|
||||
}
|
||||
if !sameOptionalUint(before.BusinessOwnerAccountID, after.BusinessOwnerAccountID) {
|
||||
accounts := businessOwnerAuditAccounts(tx, before.BusinessOwnerAccountID, after.BusinessOwnerAccountID)
|
||||
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopBusinessOwnerUpdated, Summary: "更新店铺业务员归属",
|
||||
OperatorID: operatorID, Shop: after, ParentShop: parent, Accounts: accounts,
|
||||
BeforeData: map[string]any{"business_owner_account_id": before.BusinessOwnerAccountID},
|
||||
AfterData: map[string]any{"business_owner_account_id": after.BusinessOwnerAccountID},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺业务员归属已更新",
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺业务员审计失败")
|
||||
}
|
||||
}
|
||||
if before.ClientLoginDisabled != after.ClientLoginDisabled {
|
||||
subject := "店铺 C 端登录限制已解除"
|
||||
if after.ClientLoginDisabled {
|
||||
subject = "店铺 C 端登录已限制"
|
||||
}
|
||||
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopClientLoginLimitUpdated, Summary: "更新店铺 C 端登录限制",
|
||||
OperatorID: operatorID, Shop: after, ParentShop: parent,
|
||||
BeforeData: map[string]any{"client_login_disabled": before.ClientLoginDisabled},
|
||||
AfterData: map[string]any{"client_login_disabled": after.ClientLoginDisabled},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subject,
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺登录限制审计失败")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UpdateService) recordStateFailures(ctx context.Context, shop, parent *model.Shop, request *dto.UpdateShopRequest, operatorID uint, originalErr error) {
|
||||
if shop == nil {
|
||||
return
|
||||
}
|
||||
record := func(action, summary string) {
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
|
||||
ActionCode: action, Summary: summary, Result: shopAuditFailureResult(originalErr),
|
||||
OperatorID: operatorID, Shop: shop, ParentShop: parent, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}, originalErr)
|
||||
}
|
||||
if shop.Status != request.Status {
|
||||
action := constants.AuditActionShopDisabled
|
||||
if request.Status == constants.StatusEnabled {
|
||||
action = constants.AuditActionShopEnabled
|
||||
}
|
||||
record(action, "更新店铺状态失败")
|
||||
}
|
||||
if request.BusinessOwnerAccountIDSet && !sameOptionalUint(shop.BusinessOwnerAccountID, request.BusinessOwnerAccountID) {
|
||||
record(constants.AuditActionShopBusinessOwnerUpdated, "更新店铺业务员归属失败")
|
||||
}
|
||||
if request.ClientLoginDisabled != nil && shop.ClientLoginDisabled != *request.ClientLoginDisabled {
|
||||
record(constants.AuditActionShopClientLoginLimitUpdated, "更新店铺 C 端登录限制失败")
|
||||
}
|
||||
}
|
||||
|
||||
func businessOwnerAuditAccounts(tx *gorm.DB, beforeID, afterID *uint) []accessauditapp.AccountChange {
|
||||
changes := make([]accessauditapp.AccountChange, 0, 2)
|
||||
if account := loadAuditAccount(tx, beforeID); account != nil {
|
||||
changes = append(changes, accessauditapp.AccountChange{
|
||||
Account: account, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopPreviousBusinessOwner,
|
||||
BeforeData: map[string]any{"assigned": true}, AfterData: map[string]any{"assigned": false},
|
||||
})
|
||||
}
|
||||
if account := loadAuditAccount(tx, afterID); account != nil {
|
||||
changes = append(changes, accessauditapp.AccountChange{
|
||||
Account: account, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopBusinessOwner,
|
||||
BeforeData: map[string]any{"assigned": false}, AfterData: map[string]any{"assigned": true},
|
||||
})
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func loadAuditAccount(tx *gorm.DB, accountID *uint) *model.Account {
|
||||
if accountID == nil {
|
||||
return nil
|
||||
}
|
||||
var account model.Account
|
||||
if err := tx.Unscoped().First(&account, *accountID).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &account
|
||||
}
|
||||
|
||||
func sameOptionalUint(left, right *uint) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == nil && right == nil
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func requestedShopProfileChanged(shop *model.Shop, request *dto.UpdateShopRequest) bool {
|
||||
return shop.ShopName != request.ShopName || shop.ContactName != request.ContactName ||
|
||||
shop.ContactPhone != request.ContactPhone || shop.Province != request.Province || shop.City != request.City ||
|
||||
shop.District != request.District || shop.Address != request.Address
|
||||
}
|
||||
|
||||
func loadAuditParentShop(tx *gorm.DB, parentID *uint) *model.Shop {
|
||||
if parentID == nil {
|
||||
return nil
|
||||
}
|
||||
var parent model.Shop
|
||||
if err := tx.Unscoped().First(&parent, *parentID).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &parent
|
||||
}
|
||||
|
||||
func validateUpdatedBusinessOwner(tx *gorm.DB, requestedID *uint) (*uint, error) {
|
||||
if requestedID == nil {
|
||||
return nil, nil
|
||||
|
||||
@@ -3,8 +3,7 @@ package systemconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
configinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
@@ -24,13 +24,17 @@ type ChangeAudit struct {
|
||||
OperationType string
|
||||
Description string
|
||||
ConfigKey string
|
||||
Module string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
Result string
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
}
|
||||
|
||||
// AuditWriter 可选接收系统配置事务内审计事实。
|
||||
// AuditWriter 接收系统配置事务内审计事实。
|
||||
type AuditWriter interface {
|
||||
WriteConfigChange(ctx context.Context, tx *gorm.DB, audit ChangeAudit) error
|
||||
}
|
||||
@@ -69,14 +73,29 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
|
||||
if operatorID == 0 || key == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if s.audit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "系统配置审计接缝未配置")
|
||||
}
|
||||
definition, registered := s.registry.Get(key)
|
||||
if !registered {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "系统配置 Key 未注册")
|
||||
}
|
||||
if definition.Readonly {
|
||||
s.recordFailure(ctx, ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
|
||||
Description: "拒绝更新只读系统配置", ConfigKey: key, Module: definition.Module,
|
||||
Result: constants.AuditResultDenied, ErrorCode: strconv.Itoa(errors.CodeInvalidStatus),
|
||||
ErrorSummary: "系统配置为只读,更新请求已拒绝",
|
||||
})
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "系统配置为只读,不能更新")
|
||||
}
|
||||
if err := configinfra.ValidateValue(definition, request.Value); err != nil {
|
||||
s.recordFailure(ctx, ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
|
||||
Description: "拒绝非法系统配置值", ConfigKey: key, Module: definition.Module,
|
||||
Result: constants.AuditResultDenied, ErrorCode: strconv.Itoa(errors.CodeInvalidParam),
|
||||
ErrorSummary: "系统配置值不符合注册规则",
|
||||
})
|
||||
return nil, errors.New(errors.CodeInvalidParam, "系统配置值不符合注册规则")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
@@ -121,21 +140,24 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
if s.audit != nil {
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: "system_config_update", Description: "更新受控系统配置",
|
||||
ConfigKey: key,
|
||||
BeforeData: map[string]any{"config_key": key, "value": auditValue(definition, beforeValue)},
|
||||
AfterData: map[string]any{"config_key": key, "value": auditValue(definition, request.Value)},
|
||||
RequestID: requestID, CorrelationID: requestID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate, Description: "更新受控系统配置",
|
||||
ConfigKey: key, Module: definition.Module,
|
||||
BeforeData: auditData(definition, beforeValue), AfterData: auditData(definition, request.Value),
|
||||
RequestID: requestID, CorrelationID: requestID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
saved = existing
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
|
||||
Description: "系统配置更新失败", ConfigKey: key, Module: definition.Module,
|
||||
Result: constants.AuditResultFailed, ErrorCode: strconv.Itoa(errors.CodeDatabaseError),
|
||||
ErrorSummary: "系统配置更新事务已回滚",
|
||||
})
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新系统配置失败")
|
||||
}
|
||||
s.registry.Remember(key, request.Value)
|
||||
@@ -159,10 +181,24 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
|
||||
}, nil
|
||||
}
|
||||
|
||||
func auditValue(definition configinfra.Definition, value string) string {
|
||||
if !definition.Sensitive {
|
||||
return value
|
||||
func (s *UpdateService) recordFailure(ctx context.Context, audit ChangeAudit) {
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
audit.RequestID = *value
|
||||
audit.CorrelationID = *value
|
||||
}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.audit.WriteConfigChange(ctx, tx, audit)
|
||||
})
|
||||
if err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(
|
||||
audit.OperationType, audit.ConfigKey, audit.RequestID, audit.CorrelationID, audit.ErrorCode, err,
|
||||
)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return "[敏感值 sha256:" + hex.EncodeToString(sum[:8]) + "]"
|
||||
}
|
||||
|
||||
func auditData(definition configinfra.Definition, value string) map[string]any {
|
||||
if definition.Sensitive {
|
||||
return map[string]any{"credentials_configured": value != ""}
|
||||
}
|
||||
return map[string]any{"value": value}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ func NewChangeCreditService(db *gorm.DB) *ChangeCreditService {
|
||||
return &ChangeCreditService{db: db}
|
||||
}
|
||||
|
||||
// Execute 按主钱包类型和版本条件更新,不修改余额、冻结金额或钱包流水。
|
||||
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64, version int) (*dto.ShopCreditLimitResponse, error) {
|
||||
// Execute 使用服务端读取的版本条件更新,不修改余额、冻结金额或钱包流水。
|
||||
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64) (*dto.ShopCreditLimitResponse, error) {
|
||||
var result *dto.ShopCreditLimitResponse
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var stored model.AgentWallet
|
||||
@@ -35,7 +35,7 @@ func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled
|
||||
return err
|
||||
}
|
||||
update := tx.Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, version, aggregate.EffectiveCredit()).
|
||||
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, stored.Version, aggregate.EffectiveCredit()).
|
||||
Updates(map[string]any{"credit_enabled": enabled, "credit_limit": limit, "version": gorm.Expr("version + 1")})
|
||||
if update.Error != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, update.Error, "更新店铺信用额度失败")
|
||||
@@ -45,13 +45,13 @@ func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled
|
||||
if err := tx.Where("id = ? AND wallet_type = ?", stored.ID, constants.AgentWalletTypeMain).First(¤t).Error; err != nil {
|
||||
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
|
||||
}
|
||||
if current.Version != version {
|
||||
return errors.New(errors.CodeConflict, "钱包版本已变化,请刷新后重试")
|
||||
if current.Version != stored.Version {
|
||||
return errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
|
||||
}
|
||||
return errors.New(errors.CodeInsufficientQuota, "当前资金占用无法降低或关闭信用额度")
|
||||
}
|
||||
available, _ := aggregate.AvailableBalance()
|
||||
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: version + 1}
|
||||
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: stored.Version + 1}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
|
||||
@@ -38,14 +38,44 @@ type AccessTokenProvider interface {
|
||||
Invalidate(ctx context.Context, applicationID uint)
|
||||
}
|
||||
|
||||
// SensitiveReadAuditWriter 定义明文连接凭据读取的失败关闭审计边界。
|
||||
type SensitiveReadAuditWriter interface {
|
||||
WriteSensitiveRead(ctx context.Context, tx *gorm.DB, audit SensitiveReadAudit) error
|
||||
}
|
||||
|
||||
// SensitiveReadAudit 是一次企业微信应用明文凭据读取事实。
|
||||
type SensitiveReadAudit struct {
|
||||
OperatorID uint
|
||||
Applications []SensitiveReadResource
|
||||
FieldClasses []string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// SensitiveReadResource 是不含任何明文凭据的读取目标快照。
|
||||
type SensitiveReadResource struct {
|
||||
ID uint
|
||||
CorpID string
|
||||
AgentID int64
|
||||
Name string
|
||||
Status int
|
||||
CredentialsConfigured bool
|
||||
}
|
||||
|
||||
// ConnectionService 保存应用配置并测试企业微信连接。
|
||||
type ConnectionService struct {
|
||||
db *gorm.DB
|
||||
repo ApplicationRepository
|
||||
tokens AccessTokenProvider
|
||||
audit systemconfigapp.AuditWriter
|
||||
members DefaultCreatorMemberFinder
|
||||
now func() time.Time
|
||||
db *gorm.DB
|
||||
repo ApplicationRepository
|
||||
tokens AccessTokenProvider
|
||||
audit systemconfigapp.AuditWriter
|
||||
readAudit SensitiveReadAuditWriter
|
||||
members DefaultCreatorMemberFinder
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// SetSensitiveReadAuditWriter 注入明文凭据读取的失败关闭审计 Writer。
|
||||
func (s *ConnectionService) SetSensitiveReadAuditWriter(writer SensitiveReadAuditWriter) {
|
||||
s.readAudit = writer
|
||||
}
|
||||
|
||||
// SetDefaultCreatorMemberFinder 注入默认审批发起人的可见成员查询边界。
|
||||
@@ -156,6 +186,33 @@ func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicati
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(applications) > 0 {
|
||||
if s.db == nil || s.readAudit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "敏感读取审计能力未配置")
|
||||
}
|
||||
requestID := ""
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
resources := make([]SensitiveReadResource, 0, len(applications))
|
||||
for _, application := range applications {
|
||||
resources = append(resources, SensitiveReadResource{
|
||||
ID: application.ID, CorpID: application.CorpID, AgentID: application.AgentID,
|
||||
Name: application.Name, Status: application.Status,
|
||||
CredentialsConfigured: application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "",
|
||||
})
|
||||
}
|
||||
readAudit := SensitiveReadAudit{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), Applications: resources,
|
||||
FieldClasses: []string{"secret", "callback_token", "encoding_aes_key"},
|
||||
RequestID: requestID, CorrelationID: requestID,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.readAudit.WriteSensitiveRead(ctx, tx, readAudit)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
result := make([]dto.WeComApplicationResponse, 0, len(applications))
|
||||
for _, application := range applications {
|
||||
result = append(result, toApplicationResponse(application))
|
||||
|
||||
@@ -28,5 +28,5 @@ type Dependencies struct {
|
||||
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil)
|
||||
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
|
||||
SystemConfigRegistry *systemConfigInfra.Registry // 业务模块共享的受控配置注册表(可选)
|
||||
SystemConfigAudit systemConfigApp.AuditWriter // 配置变更审计 Port(可选,装配后与配置同事务写入)
|
||||
SystemConfigAudit systemConfigApp.AuditWriter // 配置变更审计 Port;生产为空时装配统一 Writer
|
||||
}
|
||||
|
||||
@@ -12,6 +12,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"
|
||||
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"
|
||||
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
@@ -19,7 +20,9 @@ import (
|
||||
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
|
||||
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
|
||||
auditQuery "github.com/break/junhong_cmp_fiber/internal/query/audit"
|
||||
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
|
||||
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
|
||||
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
|
||||
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
|
||||
@@ -107,8 +110,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader)
|
||||
clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy)
|
||||
systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader)
|
||||
systemConfigAudit := deps.SystemConfigAudit
|
||||
if systemConfigAudit == nil {
|
||||
systemConfigAudit = auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
|
||||
}
|
||||
systemConfigUpdate := systemConfigApp.NewUpdateService(
|
||||
deps.DB, systemConfigRegistry, systemConfigCache, deps.SystemConfigAudit, systemConfigAlerts, nil,
|
||||
deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAudit, systemConfigAlerts, nil,
|
||||
)
|
||||
wecomRepository := wecomInfra.NewApplicationRepository(deps.DB)
|
||||
wecomBaseURL := ""
|
||||
@@ -122,8 +129,11 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
wecomBaseURL, wecomTimeout, deps.Logger,
|
||||
)
|
||||
wecomConnections := wecomApp.NewConnectionService(
|
||||
deps.DB, wecomRepository, wecomTokens, deps.SystemConfigAudit,
|
||||
deps.DB, wecomRepository, wecomTokens, systemConfigAudit,
|
||||
)
|
||||
if sensitiveReadAudit, ok := systemConfigAudit.(wecomApp.SensitiveReadAuditWriter); ok {
|
||||
wecomConnections.SetSensitiveReadAuditWriter(sensitiveReadAudit)
|
||||
}
|
||||
wecomMembers := wecomInfra.NewMemberRepository(deps.DB)
|
||||
wecomConnections.SetDefaultCreatorMemberFinder(wecomMembers)
|
||||
wecomDirectory := wecomApp.NewDirectoryService(
|
||||
@@ -146,7 +156,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
Account: admin.NewAccountHandler(svc.Account),
|
||||
Role: func() *admin.RoleHandler {
|
||||
handler := admin.NewRoleHandler(svc.Role, validate)
|
||||
handler.SetDefaultCreditService(roleApp.NewDefaultCreditService(deps.DB, svc.Permission))
|
||||
handler.SetDefaultCreditService(roleApp.NewDefaultCreditService(deps.DB, svc.Permission, svc.AccessAudit))
|
||||
return handler
|
||||
}(),
|
||||
Permission: admin.NewPermissionHandler(svc.Permission),
|
||||
@@ -180,8 +190,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate),
|
||||
Shop: func() *admin.ShopHandler {
|
||||
handler := admin.NewShopHandler(svc.Shop, validate)
|
||||
handler.SetCreateService(shopApp.NewCreateService(deps.DB))
|
||||
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB))
|
||||
handler.SetCreateService(shopApp.NewCreateService(deps.DB, svc.AccessAudit))
|
||||
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB, svc.AccessAudit))
|
||||
handler.SetBusinessOwnerQuery(shopQuery.NewBusinessOwnerQuery(deps.DB))
|
||||
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB))
|
||||
return handler
|
||||
@@ -280,6 +290,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
|
||||
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
|
||||
SystemConfig: admin.NewSystemConfigHandler(systemConfigList, systemConfigUpdate),
|
||||
Audit: admin.NewAuditHandler(auditQuery.New(deps.DB), integrationQuery.New(deps.DB)),
|
||||
WeCom: func() *admin.WeComHandler {
|
||||
handler := admin.NewWeComHandler(wecomConnections, validate)
|
||||
handler.SetDirectoryService(wecomDirectory)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
accessauditApp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||||
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
|
||||
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
|
||||
exchangeInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/exchange"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
@@ -76,6 +78,7 @@ import (
|
||||
)
|
||||
|
||||
type services struct {
|
||||
AccessAudit accessauditApp.Writer
|
||||
Approval *approvalApp.CreationService
|
||||
Account *accountSvc.Service
|
||||
AccountAudit *accountAuditSvc.Service
|
||||
@@ -147,7 +150,13 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopPackageAllocation)
|
||||
accountAudit := accountAuditSvc.NewService(s.AccountOperationLog)
|
||||
assetAudit := assetAuditSvc.NewService(s.AssetOperationLog, deps.DB)
|
||||
auditWriter := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
|
||||
account := accountSvc.New(s.Account, s.Role, s.AccountRole, s.ShopRole, s.Shop, s.Enterprise, accountAudit)
|
||||
account.SetLifecycleAudit(deps.DB, auditWriter)
|
||||
account.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
|
||||
account.SetTokenManager(deps.TokenManager)
|
||||
authService := authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger)
|
||||
authService.SetSecurityAudit(deps.DB, auditWriter)
|
||||
|
||||
// 创建 IotCard service 并设置回调
|
||||
iotCard := iotCardSvc.New(
|
||||
@@ -330,15 +339,22 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
refundService.SetRefundApprovalCreationService(
|
||||
refundapprovalApp.NewCreationService(deps.DB, approvalCreationService),
|
||||
)
|
||||
roleService := roleSvc.New(s.Role, s.Permission, s.RolePermission, s.AccountRole, s.ShopRole)
|
||||
roleService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
|
||||
permissionService := permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis)
|
||||
permissionService.SetAccessAudit(deps.DB, auditWriter)
|
||||
shopService := shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role)
|
||||
shopService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
|
||||
|
||||
return &services{
|
||||
AccessAudit: auditWriter,
|
||||
Approval: approvalCreationService,
|
||||
Account: account,
|
||||
AccountAudit: accountAudit,
|
||||
AssetAudit: assetAudit,
|
||||
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission, s.AccountRole, s.ShopRole),
|
||||
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
|
||||
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger),
|
||||
Role: roleService,
|
||||
Permission: permissionService,
|
||||
PersonalCustomer: personalCustomerSvc.NewService(deps.DB, s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger, auditWriter),
|
||||
ClientAuth: clientAuthSvc.New(
|
||||
deps.DB,
|
||||
s.PersonalCustomerOpenID,
|
||||
@@ -352,9 +368,10 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
deps.Redis,
|
||||
deps.Logger,
|
||||
customerBinding,
|
||||
auditWriter,
|
||||
),
|
||||
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role),
|
||||
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
|
||||
Shop: shopService,
|
||||
Auth: authService,
|
||||
ShopCommission: shopCommission,
|
||||
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
|
||||
CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting),
|
||||
@@ -376,10 +393,10 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
||||
deps.Logger,
|
||||
),
|
||||
Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account),
|
||||
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard),
|
||||
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger),
|
||||
Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger),
|
||||
Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account, auditWriter),
|
||||
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard, auditWriter),
|
||||
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger, auditWriter),
|
||||
Authorization: enterpriseCardSvc.NewAuthorizationService(deps.DB, s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger, auditWriter),
|
||||
IotCard: iotCard,
|
||||
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, assetAudit),
|
||||
ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService),
|
||||
|
||||
@@ -75,6 +75,7 @@ type Handlers struct {
|
||||
ClientWechat *app.ClientWechatHandler
|
||||
SuperAdmin *admin.SuperAdminHandler
|
||||
SystemConfig *admin.SystemConfigHandler
|
||||
Audit *admin.AuditHandler
|
||||
WeCom *admin.WeComHandler
|
||||
AgentOpenAPI *openapiHandler.Handler
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type Entry struct {
|
||||
PrimaryResource string `json:"primary_resource,omitempty"`
|
||||
AffectedResource string `json:"affected_resource,omitempty"`
|
||||
ActorSource string `json:"actor_source"`
|
||||
Visibility string `json:"visibility"`
|
||||
Transaction string `json:"transaction"`
|
||||
FailureStrategy string `json:"failure_strategy"`
|
||||
SensitivePolicy string `json:"sensitive_policy"`
|
||||
@@ -46,7 +47,10 @@ type Entry struct {
|
||||
// Scan 扫描当前仓库中对外 HTTP、Asynq Worker 和定时任务注册入口。
|
||||
func Scan(root string) ([]Entry, error) {
|
||||
var entries []Entry
|
||||
files := []string{"internal/routes", "internal/application", "internal/domain", "internal/service", "pkg/queue", "cmd/worker"}
|
||||
files := []string{
|
||||
"internal/routes", "internal/application", "internal/domain", "internal/service",
|
||||
"internal/handler", "internal/infrastructure", "internal/polling", "pkg/queue", "cmd/worker",
|
||||
}
|
||||
for _, directory := range files {
|
||||
err := filepath.Walk(filepath.Join(root, directory), func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
@@ -108,7 +112,10 @@ func scanFile(root, path string) ([]Entry, error) {
|
||||
if identifier, ok := call.Fun.(*ast.Ident); ok && identifier.Name == "Register" && len(call.Args) >= 7 {
|
||||
method, methodOK := stringLiteral(call.Args[3])
|
||||
pathSuffix, pathOK := stringLiteral(call.Args[4])
|
||||
if methodOK && pathOK {
|
||||
if !pathOK {
|
||||
pathSuffix = expression(call.Args[4])
|
||||
}
|
||||
if methodOK {
|
||||
entry := classifyHTTP(relative, position.Line, method, pathSuffix, expression(call.Args[5]), routeSummary(call.Args[6]))
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
@@ -129,6 +136,12 @@ func scanFile(root, path string) ([]Entry, error) {
|
||||
entries = append(entries, classifySchedule(relative, position.Line, taskType, schedule))
|
||||
}
|
||||
}
|
||||
case "LogOperation":
|
||||
entries = append(entries, classifyLegacyWriter(relative, position.Line, expression(call.Fun)))
|
||||
case "Start", "Complete", "RecordInbound":
|
||||
if isIntegrationLogCall(relative, expression(selector.X)) {
|
||||
entries = append(entries, classifyIntegrationLog(relative, position.Line, expression(call.Fun)))
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -136,7 +149,7 @@ func scanFile(root, path string) ([]Entry, error) {
|
||||
strings.HasPrefix(relative, "internal/service/") {
|
||||
for _, declaration := range file.Decls {
|
||||
function, ok := declaration.(*ast.FuncDecl)
|
||||
if !ok || function.Recv == nil || !isBusinessMethod(function.Name.Name) {
|
||||
if !ok || function.Recv == nil || !isBusinessMethod(function) {
|
||||
continue
|
||||
}
|
||||
position := set.Position(function.Pos())
|
||||
@@ -154,14 +167,15 @@ func classifyHTTP(file string, line int, method, path, handler, summary string)
|
||||
entry := Entry{
|
||||
Key: fmt.Sprintf("http:%s:%d:%s:%s", file, line, method, path), Kind: "http",
|
||||
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, handler), Owner: owner,
|
||||
Method: method, Path: path, Summary: summary, ActorSource: httpActorSource(file),
|
||||
Method: method, Path: path, Summary: summary, ActorSource: httpActorSource(file, path, handler),
|
||||
DomainLedger: ledgerDecision(owner), IntegrationLog: integrationDecision(file, path),
|
||||
Outbox: "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A",
|
||||
Visibility: httpVisibility(file, path, handler),
|
||||
SensitivePolicy: "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限",
|
||||
BeforeAfterPolicy: "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用",
|
||||
TestSeam: "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口",
|
||||
}
|
||||
if method == "GET" && !isSensitiveRead(file, path, summary) {
|
||||
if isReadOnlyHTTP(method, path) && !isSensitiveRead(file, path, summary) {
|
||||
entry.AuditEvent = "N/A"
|
||||
entry.Transaction = "N/A"
|
||||
entry.FailureStrategy = "Access Log 记录统一错误;普通读取不创建业务审计"
|
||||
@@ -192,6 +206,7 @@ func classifyWorker(file string, line int, taskType, handler string) Entry {
|
||||
Category: categoryFor(owner), Risk: riskFor(owner, taskType, handler),
|
||||
PrimaryResource: owner, AffectedResource: "任务载荷定位的直接业务资源",
|
||||
ActorSource: "system_task/asynq", Transaction: "业务状态变化、领域流水和 Audit Event 按用例原子提交",
|
||||
Visibility: "内部系统入口;外部主体只读取对应业务安全投影",
|
||||
FailureStrategy: "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计",
|
||||
SensitivePolicy: "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL",
|
||||
BeforeAfterPolicy: "状态变化保存直接前后值;无业务变化时仅保留 Integration Log",
|
||||
@@ -206,6 +221,7 @@ func classifySchedule(file string, line int, taskType, schedule string) Entry {
|
||||
CodeEntry: fmt.Sprintf("%s:%d", file, line), Owner: workerOwner(taskType), Summary: "按 " + schedule + " 调度 " + taskType,
|
||||
AuditEvent: "N/A", DomainLedger: "N/A", IntegrationLog: "N/A", Outbox: "N/A",
|
||||
ActorSource: "system_task/scheduled_job", Transaction: "N/A",
|
||||
Visibility: "内部系统入口,不直接对用户展示",
|
||||
FailureStrategy: "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责",
|
||||
SensitivePolicy: "调度日志仅记录任务类型与安全时间信息",
|
||||
BeforeAfterPolicy: "N/A:调度入口不修改业务事实",
|
||||
@@ -230,6 +246,7 @@ func classifyBusinessMethod(file string, line int, method string) Entry {
|
||||
ActionCode: actionCode(owner, method), Risk: riskFor(owner, file, method),
|
||||
PrimaryResource: owner, AffectedResource: "完整用例直接修改或引用的资源",
|
||||
ActorSource: "由调用入口传入操作者与来源快照",
|
||||
Visibility: "由完整用例决定平台完整视图、主体安全投影或 internal_only",
|
||||
SensitivePolicy: "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品",
|
||||
BeforeAfterPolicy: "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影",
|
||||
TestSeam: "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口",
|
||||
@@ -254,6 +271,45 @@ func classifyBusinessMethod(file string, line int, method string) Entry {
|
||||
return entry
|
||||
}
|
||||
|
||||
func classifyLegacyWriter(file string, line int, call string) Entry {
|
||||
return Entry{
|
||||
Key: fmt.Sprintf("legacy_writer:%s:%d:%s", file, line, call), Kind: "legacy_writer",
|
||||
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, call), Owner: filepath.Base(filepath.Dir(file)),
|
||||
Summary: "调用旧 Operation Log Writer", AuditEvent: "必须迁移到统一 Audit Event 后停写旧表",
|
||||
DomainLedger: "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger",
|
||||
IntegrationLog: "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录",
|
||||
Outbox: "由原完整用例决定,旧 Writer 不得替代 Outbox",
|
||||
ActionCode: actionCode(filepath.Base(filepath.Dir(file)), call), ActionName: "迁移旧审计写入",
|
||||
Category: categoryFor(file), Risk: riskFor(file, call, ""), PrimaryResource: filepath.Base(filepath.Dir(file)),
|
||||
AffectedResource: "按原完整业务用例登记实际资源", ActorSource: "沿用原调用入口真实操作者",
|
||||
Visibility: "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影",
|
||||
Transaction: "迁移后关键成功与业务事实同事务,旧异步 Writer 停写",
|
||||
FailureStrategy: "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine",
|
||||
SensitivePolicy: "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据",
|
||||
BeforeAfterPolicy: "按资源保存本次直接变化,不复制旧单体 JSON",
|
||||
TestSeam: "静态调用归零扫描 + 对应业务入口与数据库抽样核对",
|
||||
}
|
||||
}
|
||||
|
||||
func classifyIntegrationLog(file string, line int, call string) Entry {
|
||||
return Entry{
|
||||
Key: fmt.Sprintf("integration_log:%s:%d:%s", file, line, call), Kind: "integration_log",
|
||||
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, call), Owner: filepath.Base(filepath.Dir(file)),
|
||||
Summary: "记录外部交互尝试或终态", AuditEvent: "N/A",
|
||||
DomainLedger: "N/A:Integration Log 只记录外部交互事实,不替代内部业务表",
|
||||
IntegrationLog: "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要",
|
||||
Outbox: "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox",
|
||||
ActorSource: "external_system 或发起外呼的真实 Application/Worker/Callback",
|
||||
Visibility: "仅平台内部调查完整可见;代理/企业不得读取外部交互细节",
|
||||
Transaction: "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event",
|
||||
FailureStrategy: "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功",
|
||||
SensitivePolicy: "请求、响应和 metadata 写入前删除凭据,历史读取再次清理",
|
||||
BeforeAfterPolicy: "N/A:保存外部尝试结构化摘要和本地状态是否变化",
|
||||
TestSeam: "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对",
|
||||
NAReason: "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event",
|
||||
}
|
||||
}
|
||||
|
||||
func routeSummary(expr ast.Expr) string {
|
||||
composite, ok := expr.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
@@ -305,13 +361,18 @@ func expression(expr ast.Expr) string {
|
||||
return value.Value
|
||||
case *ast.CallExpr:
|
||||
return expression(value.Fun)
|
||||
case *ast.BinaryExpr:
|
||||
return expression(value.X) + value.Op.String() + expression(value.Y)
|
||||
default:
|
||||
return fmt.Sprintf("%T", expr)
|
||||
}
|
||||
}
|
||||
|
||||
func httpActorSource(file string) string {
|
||||
func httpActorSource(file, path, handler string) string {
|
||||
text := strings.ToLower(file + " " + path + " " + handler)
|
||||
switch {
|
||||
case strings.Contains(text, "callback") || strings.Contains(path, "/carriers/"):
|
||||
return "external_system/callback"
|
||||
case strings.HasSuffix(file, "personal.go"):
|
||||
return "personal_customer/personal_api"
|
||||
case strings.HasSuffix(file, "order.go"):
|
||||
@@ -321,14 +382,46 @@ func httpActorSource(file string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func httpVisibility(file, path, handler string) string {
|
||||
text := strings.ToLower(file + " " + path + " " + handler)
|
||||
switch {
|
||||
case strings.Contains(text, "callback"):
|
||||
return "外部回调入口;只记录内部完整事实,不直接向外部主体展示"
|
||||
case strings.Contains(text, "/audit"):
|
||||
return "仅超级管理员和平台账号可见"
|
||||
case strings.Contains(text, "enterprise"):
|
||||
return "企业认证上下文范围内可见;内部审计字段不可见"
|
||||
case strings.Contains(text, "personal"):
|
||||
return "当前个人客户本人范围内可见"
|
||||
default:
|
||||
return "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离"
|
||||
}
|
||||
}
|
||||
|
||||
func isIntegrationLogCall(file, receiver string) bool {
|
||||
if strings.Contains(file, "/integrationlog/") {
|
||||
return false
|
||||
}
|
||||
receiver = strings.ToLower(receiver)
|
||||
return strings.Contains(receiver, "integration")
|
||||
}
|
||||
|
||||
func isSensitiveRead(file, path, summary string) bool {
|
||||
text := strings.ToLower(file + " " + path + " " + summary)
|
||||
for _, marker := range []string{"download", "export", "realname-link", "实名", "敏感", "完整", "operation-password"} {
|
||||
for _, marker := range []string{"download", "realname-link", "realname/link", "实名链接", "敏感", "realtime-status"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return strings.HasSuffix(file, "wecom.go") && path == "/applications" ||
|
||||
strings.HasSuffix(file, "export_task.go") && path == "/:id"
|
||||
}
|
||||
|
||||
func isReadOnlyHTTP(method, path string) bool {
|
||||
if method == "GET" {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(path, "purchase-check") || strings.Contains(path, "verify-asset")
|
||||
}
|
||||
|
||||
func integrationDecision(file, path string) string {
|
||||
@@ -412,7 +505,11 @@ func workerOwner(taskType string) string {
|
||||
return normalize(strings.TrimPrefix(taskType, "constants.TaskType"))
|
||||
}
|
||||
|
||||
func isBusinessMethod(name string) bool {
|
||||
func isBusinessMethod(function *ast.FuncDecl) bool {
|
||||
name := function.Name.Name
|
||||
if strings.HasPrefix(name, "Set") && !hasContextParameter(function) {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range []string{
|
||||
"Create", "Update", "Delete", "Set", "Assign", "Remove", "Cancel", "Reject", "Approve",
|
||||
"Import", "Allocate", "Recall", "Stop", "Resume", "Bind", "Unbind", "Reset", "Activate",
|
||||
@@ -429,6 +526,19 @@ func isBusinessMethod(name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func hasContextParameter(function *ast.FuncDecl) bool {
|
||||
if function.Type.Params == nil {
|
||||
return false
|
||||
}
|
||||
for _, field := range function.Type.Params.List {
|
||||
selector, ok := field.Type.(*ast.SelectorExpr)
|
||||
if ok && expression(selector) == "context.Context" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalize(value string) string {
|
||||
value = strings.Trim(value, "\"")
|
||||
var output []rune
|
||||
|
||||
@@ -452,6 +452,10 @@ func (h *AssetHandler) Orders(c *fiber.Ctx) error {
|
||||
// OperationLogs 查询资产操作审计日志
|
||||
// GET /api/admin/assets/:identifier/operation-logs
|
||||
func (h *AssetHandler) OperationLogs(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
if h.assetAuditService == nil {
|
||||
return errors.New(errors.CodeInternalError, "资产审计服务未配置")
|
||||
}
|
||||
|
||||
265
internal/handler/admin/audit.go
Normal file
265
internal/handler/admin/audit.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
auditquery "github.com/break/junhong_cmp_fiber/internal/query/audit"
|
||||
integrationquery "github.com/break/junhong_cmp_fiber/internal/query/integration"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
// AuditHandler 提供平台基础审计调查只读接口。
|
||||
type AuditHandler struct {
|
||||
auditQuery *auditquery.Query
|
||||
integrationQuery *integrationquery.Query
|
||||
}
|
||||
|
||||
// NewAuditHandler 创建平台基础审计调查 Handler。
|
||||
func NewAuditHandler(auditQuery *auditquery.Query, integrationQuery *integrationquery.Query) *AuditHandler {
|
||||
return &AuditHandler{auditQuery: auditQuery, integrationQuery: integrationQuery}
|
||||
}
|
||||
|
||||
// ListEvents 查询平台全局审计事件。
|
||||
// GET /api/admin/audit/events
|
||||
func (h *AuditHandler) ListEvents(c *fiber.Ctx) error {
|
||||
var request dto.AuditEventListRequest
|
||||
if err := c.QueryParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
|
||||
if err != nil || invalidAuditPage(request.Page, request.PageSize) {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.auditQuery.List(c.UserContext(), auditquery.EventFilter{
|
||||
CreatedFrom: from, CreatedTo: to, Action: request.Action, Category: request.Category,
|
||||
ActorKind: request.ActorKind, ActorID: request.ActorID, Source: request.Source,
|
||||
Result: request.Result, Risk: request.Risk, ScopeType: request.ScopeType, ScopeID: request.ScopeID,
|
||||
ResourceType: request.ResourceType, ResourceID: request.ResourceID, ResourceKey: request.ResourceKey,
|
||||
RequestID: request.RequestID, CorrelationID: request.CorrelationID,
|
||||
Page: request.Page, PageSize: request.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// GetEvent 查询单个稳定审计事件详情。
|
||||
// GET /api/admin/audit/events/:event_id
|
||||
func (h *AuditHandler) GetEvent(c *fiber.Ctx) error {
|
||||
result, err := h.auditQuery.Get(c.UserContext(), c.Params("event_id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// ListActorEvents 查询操作者行为时间线。
|
||||
// GET /api/admin/audit/actors/:kind/:id/events
|
||||
func (h *AuditHandler) ListActorEvents(c *fiber.Ctx) error {
|
||||
var request dto.AuditActorEventsRequest
|
||||
if err := c.QueryParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
request.Kind, request.ID = c.Params("kind"), c.Params("id")
|
||||
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
|
||||
if err != nil || invalidAuditPage(request.Page, request.PageSize) {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.auditQuery.ListActorEvents(c.UserContext(), auditquery.ActorEventFilter{
|
||||
Kind: request.Kind, ID: request.ID, Action: request.Action, Result: request.Result, Risk: request.Risk,
|
||||
ResourceType: request.ResourceType, ResourceID: request.ResourceID,
|
||||
CreatedFrom: from, CreatedTo: to, Page: request.Page, PageSize: request.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// SearchResources 按注册业务标识精确搜索资源。
|
||||
// GET /api/admin/audit/resources/search
|
||||
func (h *AuditHandler) SearchResources(c *fiber.Ctx) error {
|
||||
var request dto.AuditResourceSearchRequest
|
||||
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.auditQuery.SearchResources(c.UserContext(), auditquery.ResourceSearchFilter{
|
||||
ResourceType: request.ResourceType, Keyword: request.Keyword,
|
||||
Page: request.Page, PageSize: request.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// ResourceTimeline 查询资源作为任意关系参与的通用事件时间线。
|
||||
// GET /api/admin/audit/resources/:resource_type/:resource_id/timeline
|
||||
func (h *AuditHandler) ResourceTimeline(c *fiber.Ctx) error {
|
||||
var request dto.AuditResourceTimelineRequest
|
||||
if err := c.QueryParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
request.ResourceType, request.ResourceID = c.Params("resource_type"), c.Params("resource_id")
|
||||
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
|
||||
if err != nil || invalidAuditPage(request.Page, request.PageSize) {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.auditQuery.ResourceTimeline(c.UserContext(), auditquery.ResourceTimelineFilter{
|
||||
ResourceType: request.ResourceType, ResourceID: request.ResourceID,
|
||||
CreatedFrom: from, CreatedTo: to, Action: request.Action, Result: request.Result,
|
||||
Page: request.Page, PageSize: request.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// AgentResourceActivities 查询代理范围内的安全资源活动。
|
||||
// GET /api/admin/agent/resource-activities/:resource_type/:identifier
|
||||
func (h *AuditHandler) AgentResourceActivities(c *fiber.Ctx) error {
|
||||
request, err := subjectActivityRequest(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := h.auditQuery.AgentResourceActivities(c.UserContext(), auditquery.SubjectActivityFilter{
|
||||
ResourceType: request.ResourceType, Identifier: request.Identifier,
|
||||
Page: request.Page, PageSize: request.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// EnterpriseResourceActivities 查询企业当前有效授权资产的安全资源活动。
|
||||
// GET /api/admin/enterprise/resource-activities/:resource_type/:identifier
|
||||
func (h *AuditHandler) EnterpriseResourceActivities(c *fiber.Ctx) error {
|
||||
request, err := subjectActivityRequest(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := h.auditQuery.EnterpriseResourceActivities(c.UserContext(), auditquery.SubjectActivityFilter{
|
||||
ResourceType: request.ResourceType, Identifier: request.Identifier,
|
||||
Page: request.Page, PageSize: request.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func subjectActivityRequest(c *fiber.Ctx) (dto.SubjectResourceActivityRequest, error) {
|
||||
var request dto.SubjectResourceActivityRequest
|
||||
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
|
||||
return request, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
request.ResourceType = c.Params("resource_type")
|
||||
request.Identifier = c.Params("identifier")
|
||||
if request.ResourceType == "" || request.Identifier == "" {
|
||||
return request, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// IntegrationOverview 查询外部集成交互总览。
|
||||
// GET /api/admin/audit/integrations/overview
|
||||
func (h *AuditHandler) IntegrationOverview(c *fiber.Ctx) error {
|
||||
var request dto.IntegrationOverviewRequest
|
||||
if err := c.QueryParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
filter, err := integrationFilter(request.IntegrationFilterRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := h.integrationQuery.Overview(c.UserContext(), integrationquery.OverviewFilter{
|
||||
ListFilter: filter,
|
||||
Bucket: request.Bucket,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// ListIntegrations 查询外部集成交互列表。
|
||||
// GET /api/admin/audit/integrations
|
||||
func (h *AuditHandler) ListIntegrations(c *fiber.Ctx) error {
|
||||
var request dto.IntegrationListRequest
|
||||
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
filter, err := integrationFilter(request.IntegrationFilterRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filter.Page, filter.PageSize = request.Page, request.PageSize
|
||||
result, err := h.integrationQuery.List(c.UserContext(), filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// GetIntegration 查询稳定外部集成记录详情。
|
||||
// GET /api/admin/audit/integrations/:integration_id
|
||||
func (h *AuditHandler) GetIntegration(c *fiber.Ctx) error {
|
||||
result, err := h.integrationQuery.Get(c.UserContext(), c.Params("integration_id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func integrationFilter(request dto.IntegrationFilterRequest) (integrationquery.ListFilter, error) {
|
||||
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
|
||||
if err != nil {
|
||||
return integrationquery.ListFilter{}, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
return integrationquery.ListFilter{
|
||||
CreatedFrom: from, CreatedTo: to, IntegrationID: request.IntegrationID,
|
||||
Provider: request.Provider, Direction: request.Direction, Operation: request.Operation,
|
||||
Result: request.Result, ResultCategory: request.ResultCategory, ExternalID: request.ExternalID,
|
||||
ResourceType: request.ResourceType, ResourceID: request.ResourceID, ResourceKey: request.ResourceKey,
|
||||
TriggerSource: request.TriggerSource, TriggerScene: request.TriggerScene, TriggerSeries: request.TriggerSeries,
|
||||
StateChanged: request.StateChanged, HTTPStatus: request.HTTPStatus, ProviderCode: request.ProviderCode,
|
||||
RequestID: request.RequestID, CorrelationID: request.CorrelationID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func auditTimeRange(fromValue, toValue string) (*time.Time, *time.Time, error) {
|
||||
from, err := optionalAuditTime(fromValue)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
to, err := optionalAuditTime(toValue)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if from != nil && to != nil && !from.Before(*to) {
|
||||
return nil, nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
func optionalAuditTime(value string) (*time.Time, error) {
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func invalidAuditPage(page, pageSize int) bool {
|
||||
return page < 0 || pageSize < 0 || pageSize > 100
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func (h *ShopHandler) UpdateCreditLimit(c *fiber.Ctx) error {
|
||||
if h.changeCreditService == nil {
|
||||
return errors.New(errors.CodeInternalError, "店铺信用额度服务未配置")
|
||||
}
|
||||
result, err := h.changeCreditService.Execute(c.UserContext(), uint(id), *request.CreditEnabled, *request.CreditLimit, *request.Version)
|
||||
result, err := h.changeCreditService.Execute(c.UserContext(), uint(id), *request.CreditEnabled, *request.CreditLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -77,3 +77,13 @@ func recordDisabledCarrierCallback(
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// completeResolvedCarrierCallback 在回调已精确解析卡后补充真实本地资源ID。
|
||||
func completeResolvedCarrierCallback(ctx context.Context, repository *integrationlog.Repository, integrationID, result string, stateChanged bool, reason string, cardID uint) error {
|
||||
resourceID := strconv.FormatUint(uint64(cardID), 10)
|
||||
_, err := repository.Complete(ctx, integrationID, integrationlog.Completion{
|
||||
Result: result, HTTPStatus: fiber.StatusOK, StateChanged: stateChanged, ResourceID: &resourceID,
|
||||
ResponseSummary: map[string]any{"reason": reason},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func (h *CMCCRealnameHandler) process(ctx context.Context, body []byte, contentT
|
||||
h.logger.Warn("移动实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
|
||||
return completeResolvedCarrierCallback(ctx, h.integration, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用", card.ID)
|
||||
}
|
||||
|
||||
func (h *CMCCRealnameHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CMCCRealnameTranslation, requestID *string) error {
|
||||
|
||||
@@ -141,7 +141,7 @@ func (h *CTCCRealnameHandler) process(ctx context.Context, body []byte, contentT
|
||||
h.logger.Warn("电信实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
|
||||
return completeResolvedCarrierCallback(ctx, h.integration, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用", card.ID)
|
||||
}
|
||||
|
||||
func (h *CTCCRealnameHandler) failPending(ctx context.Context, integrationID string, original error) error {
|
||||
|
||||
@@ -114,7 +114,7 @@ func (h *CUCCRealnameHandler) process(ctx context.Context, body []byte, contentT
|
||||
h.logger.Warn("联通实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
|
||||
return completeResolvedCarrierCallback(ctx, h.integration, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用", card.ID)
|
||||
}
|
||||
|
||||
func (h *CUCCRealnameHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CUCCRealnameTranslation, requestID *string) error {
|
||||
|
||||
@@ -93,7 +93,7 @@ func (h *CUCCRealnameRemovalHandler) process(ctx context.Context, body []byte, c
|
||||
if len(cards) > 1 {
|
||||
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, "精确列匹配多张卡")
|
||||
}
|
||||
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultIgnored, "解除实名仅留痕,不修改本地实名事实")
|
||||
return completeResolvedCarrierCallback(ctx, h.integration, log.IntegrationID, constants.IntegrationResultIgnored, false, "解除实名仅留痕,不修改本地实名事实", cards[0].ID)
|
||||
}
|
||||
|
||||
func (h *CUCCRealnameRemovalHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CUCCRealnameRemovalTranslation, requestID *string) error {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/alipay"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
|
||||
@@ -112,6 +113,7 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "微信支付服务未配置")
|
||||
}
|
||||
ctx = paymentCallbackContext(ctx, constants.IntegrationProviderWechatPay)
|
||||
|
||||
switch cfg.ProviderType {
|
||||
case model.ProviderTypeWechatV2:
|
||||
@@ -218,7 +220,9 @@ func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callba
|
||||
if h.agentPaymentConfirm == nil || h.integration == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理充值支付回调能力未配置")
|
||||
}
|
||||
resourceID, correlationID := callback.PaymentNo, callback.PaymentNo
|
||||
resourceKey, correlationID := callback.PaymentNo, callback.PaymentNo
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: correlationID})
|
||||
linkage := auditcontext.From(ctx)
|
||||
idempotencyKey := callback.TransactionID
|
||||
if idempotencyKey == "" {
|
||||
idempotencyKey = callback.PaymentNo
|
||||
@@ -226,7 +230,7 @@ func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callba
|
||||
log, _, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
|
||||
IdempotencyKey: idempotencyKey, Provider: callback.Provider,
|
||||
Operation: constants.IntegrationOperationPaymentCallback, ExternalID: callback.TransactionID,
|
||||
ResourceType: constants.IntegrationResourceTypeAgentRechargePayment, ResourceID: &resourceID,
|
||||
ResourceType: constants.IntegrationResourceTypeAgentRechargePayment, ResourceKey: &resourceKey,
|
||||
RawPayload: callback.RawPayload, ContentType: callback.ContentType,
|
||||
RequestID: pkgmiddleware.GetRequestIDFromContext(ctx), CorrelationID: &correlationID,
|
||||
})
|
||||
@@ -236,20 +240,27 @@ func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callba
|
||||
result, confirmErr := h.agentPaymentConfirm.Execute(ctx, agentrechargeApp.ConfirmOnlinePaymentCommand{
|
||||
PaymentNo: callback.PaymentNo, PaymentMethod: callback.PaymentMethod, ConfigID: callback.ConfigID,
|
||||
MerchantIdentity: callback.MerchantIdentity, ThirdPartyTradeNo: callback.TransactionID,
|
||||
Amount: callback.Amount, PaidAt: callback.PaidAt, CorrelationID: correlationID,
|
||||
Amount: callback.Amount, PaidAt: callback.PaidAt, RequestID: linkage.RequestID,
|
||||
CorrelationID: correlationID, ParentEventID: linkage.ParentEventID,
|
||||
})
|
||||
if confirmErr != nil {
|
||||
h.logger.Error("代理充值支付确认失败",
|
||||
zap.String("integration_id", log.IntegrationID),
|
||||
zap.String("payment_no", callback.PaymentNo),
|
||||
zap.Error(confirmErr),
|
||||
)
|
||||
h.completePaymentCallbackLog(ctx, log, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultFailed, ProviderMessage: confirmErr.Error(),
|
||||
Result: constants.IntegrationResultFailed, SafeProviderMessage: "代理充值支付确认失败",
|
||||
ResponseSummary: map[string]any{"confirmed": false},
|
||||
})
|
||||
return confirmErr
|
||||
}
|
||||
if log.Result == constants.IntegrationResultPending {
|
||||
resolvedResourceID := strconv.FormatUint(uint64(result.PaymentID), 10)
|
||||
_, err = h.integration.Complete(ctx, log.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultSuccess, ProviderCode: "SUCCESS",
|
||||
ResponseSummary: map[string]any{"confirmed": true, "already_confirmed": result.AlreadyConfirmed},
|
||||
StateChanged: !result.AlreadyConfirmed,
|
||||
StateChanged: !result.AlreadyConfirmed, ResourceID: &resolvedResourceID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -305,6 +316,7 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付配置不可用")
|
||||
}
|
||||
ctx = paymentCallbackContext(ctx, constants.IntegrationProviderAlipay)
|
||||
|
||||
// 使用支付宝公钥验签(DecodeNotification 内部完成签名校验)
|
||||
notification, err := alipay.DecodeNotification(ctx, cfg, values)
|
||||
@@ -513,6 +525,7 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
|
||||
)
|
||||
return c.Send(fuiou.BuildNotifyFailResponse("payment config unavailable"))
|
||||
}
|
||||
ctx = paymentCallbackContext(ctx, model.ProviderTypeFuiou)
|
||||
if cfg.ProviderType != model.ProviderTypeFuiou ||
|
||||
strings.TrimSpace(preNotify.InsCd) != strings.TrimSpace(cfg.FyInsCd) ||
|
||||
strings.TrimSpace(preNotify.MchntCd) != strings.TrimSpace(cfg.FyMchntCd) {
|
||||
@@ -604,6 +617,13 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
|
||||
return c.Send(fuiou.BuildNotifySuccessResponse())
|
||||
}
|
||||
|
||||
func paymentCallbackContext(ctx context.Context, provider string) context.Context {
|
||||
return auditcontext.With(ctx, auditcontext.Context{
|
||||
ActorKind: constants.AuditActorExternalSystem, ActorID: provider,
|
||||
ActorName: provider, Source: constants.AuditSourceCallback,
|
||||
})
|
||||
}
|
||||
|
||||
// fuiouCallbackPayload 提取富友回调载荷,兼容 body、form req 和 query req 三种来源。
|
||||
func fuiouCallbackPayload(c *fiber.Ctx) ([]byte, string) {
|
||||
if req := strings.TrimSpace(c.FormValue("req")); req != "" {
|
||||
|
||||
41
internal/infrastructure/audit/batch.go
Normal file
41
internal/infrastructure/audit/batch.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// BatchInput 描述一条批次根事件及每个已识别资源的子事件。
|
||||
type BatchInput struct {
|
||||
Root AppendInput
|
||||
Children []AppendInput
|
||||
}
|
||||
|
||||
// AppendBatch 在同一事务内追加批次根事件和资源子事件。
|
||||
func (w *Writer) AppendBatch(ctx context.Context, tx *gorm.DB, input BatchInput) error {
|
||||
if input.Root.EventID == "" {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "批次根事件缺少稳定事件ID")
|
||||
}
|
||||
if err := w.Append(ctx, tx, input.Root); err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range input.Children {
|
||||
child := input.Children[index]
|
||||
if child.EventID == "" {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "批次子事件缺少稳定事件ID")
|
||||
}
|
||||
if child.ParentEventID == "" {
|
||||
child.ParentEventID = input.Root.EventID
|
||||
}
|
||||
if child.CorrelationID == "" {
|
||||
child.CorrelationID = input.Root.CorrelationID
|
||||
}
|
||||
if err := w.Append(ctx, tx, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
410
internal/infrastructure/audit/registry.go
Normal file
410
internal/infrastructure/audit/registry.go
Normal file
@@ -0,0 +1,410 @@
|
||||
// Package audit 实现统一 Audit Event 的注册表与持久化 Adapter。
|
||||
package audit
|
||||
|
||||
import "github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
|
||||
// ActionDefinition 是受控审计动作的写入契约。
|
||||
type ActionDefinition struct {
|
||||
Code string
|
||||
Name string
|
||||
Category string
|
||||
Risk string
|
||||
PrimaryResource string
|
||||
AllowedActor string
|
||||
Source string
|
||||
RequireTransaction bool
|
||||
DefaultVisibility string
|
||||
AllowedVisibility []string
|
||||
SubjectFields []string
|
||||
SensitiveRead bool
|
||||
}
|
||||
|
||||
// ResourceDefinition 是受控审计资源的快照契约。
|
||||
type ResourceDefinition struct {
|
||||
Type string
|
||||
Name string
|
||||
IdentityFields []string
|
||||
}
|
||||
|
||||
// Registry 保存首批已评审的动作与资源定义。
|
||||
type Registry struct {
|
||||
actionsByOperation map[string]ActionDefinition
|
||||
actionsByCode map[string]ActionDefinition
|
||||
resources map[string]ResourceDefinition
|
||||
}
|
||||
|
||||
// NewRegistry 创建首批统一审计注册表。
|
||||
func NewRegistry() *Registry {
|
||||
accountCreated := accountLifecycleAction(constants.AuditActionAccountCreated, "创建账号", constants.AuditRiskNormal)
|
||||
accountUpdated := accountLifecycleAction(constants.AuditActionAccountUpdated, "更新账号", constants.AuditRiskNormal)
|
||||
accountDeleted := accountLifecycleAction(constants.AuditActionAccountDeleted, "删除账号", constants.AuditRiskHigh)
|
||||
accountPasswordReset := accountSecurityAction(constants.AuditActionAccountPasswordReset, "重置账号密码", constants.AuditRiskHigh)
|
||||
accountPasswordChanged := accountSecurityAction(constants.AuditActionAccountPasswordChanged, "修改账号密码", constants.AuditRiskHigh)
|
||||
accountWeComBound := accountSecurityAction(constants.AuditActionAccountWeComBound, "绑定账号企业微信身份", constants.AuditRiskNormal)
|
||||
authLogin := accountSecurityAction(constants.AuditActionAuthLogin, "后台账号登录", constants.AuditRiskNormal)
|
||||
authLogout := accountSecurityAction(constants.AuditActionAuthLogout, "后台账号退出登录", constants.AuditRiskNormal)
|
||||
authTokenRefreshed := accountSecurityAction(constants.AuditActionAuthTokenRefreshed, "刷新后台访问令牌", constants.AuditRiskNormal)
|
||||
accountRolesAssigned := accessAction(constants.AuditActionAccountRolesAssigned, "分配账号角色", constants.AuditResourceAccount)
|
||||
accountRoleRemoved := accessAction(constants.AuditActionAccountRoleRemoved, "移除账号角色", constants.AuditResourceAccount)
|
||||
shopRolesAssigned := accessAction(constants.AuditActionShopRolesAssigned, "分配店铺角色", constants.AuditResourceShop)
|
||||
shopRoleDeleted := accessAction(constants.AuditActionShopRoleDeleted, "移除店铺角色", constants.AuditResourceShop)
|
||||
shopRolesAssigned.Category = constants.AuditCategoryBusiness
|
||||
shopRoleDeleted.Category = constants.AuditCategoryBusiness
|
||||
shopCreated := shopIdentityAction(constants.AuditActionShopCreated, "创建店铺")
|
||||
shopUpdated := shopIdentityAction(constants.AuditActionShopUpdated, "更新店铺基础资料")
|
||||
shopEnabled := shopStateAction(constants.AuditActionShopEnabled, "启用店铺", constants.AuditRiskNormal)
|
||||
shopDisabled := shopStateAction(constants.AuditActionShopDisabled, "禁用店铺", constants.AuditRiskNormal)
|
||||
shopDeleted := shopStateAction(constants.AuditActionShopDeleted, "删除店铺", constants.AuditRiskHigh)
|
||||
shopBusinessOwnerUpdated := shopStateAction(constants.AuditActionShopBusinessOwnerUpdated, "更新店铺业务员归属", constants.AuditRiskNormal)
|
||||
shopClientLoginLimitUpdated := shopStateAction(constants.AuditActionShopClientLoginLimitUpdated, "更新店铺 C 端登录限制", constants.AuditRiskHigh)
|
||||
enterpriseCreated := enterpriseAction(constants.AuditActionEnterpriseCreated, "创建企业", constants.AuditCategoryBusiness, constants.AuditRiskNormal)
|
||||
enterpriseUpdated := enterpriseAction(constants.AuditActionEnterpriseUpdated, "更新企业基础资料", constants.AuditCategoryBusiness, constants.AuditRiskNormal)
|
||||
enterpriseStatusUpdated := enterpriseAction(constants.AuditActionEnterpriseStatusUpdated, "更新企业状态", constants.AuditCategoryBusiness, constants.AuditRiskNormal)
|
||||
enterprisePasswordUpdated := enterpriseAction(constants.AuditActionEnterprisePasswordUpdated, "更新企业账号密码", constants.AuditCategoryBusiness, constants.AuditRiskHigh)
|
||||
enterpriseCardsAllocated := enterpriseCardAction(constants.AuditActionEnterpriseCardsAllocated, "向企业授权卡")
|
||||
enterpriseCardsRecalled := enterpriseCardAction(constants.AuditActionEnterpriseCardsRecalled, "回收企业卡授权")
|
||||
enterpriseCardRemarkUpdated := enterpriseCardAction(constants.AuditActionEnterpriseCardRemarkUpdated, "更新企业卡授权备注")
|
||||
enterpriseDevicesAllocated := enterpriseCardAction(constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备")
|
||||
enterpriseDevicesRecalled := enterpriseCardAction(constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权")
|
||||
personalProfileUpdated := personalAction(constants.AuditActionPersonalCustomerProfileUpdated, "更新个人资料", []string{"nickname", "avatar_url"})
|
||||
personalPhoneBound := personalAction(constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号", []string{"phone"})
|
||||
personalPhoneChanged := personalAction(constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号", []string{"phone"})
|
||||
personalWechatIdentityUpdated := personalAction(constants.AuditActionPersonalCustomerWechatIdentityUpdated, "同步个人微信主体", []string{"app_id", "app_type"})
|
||||
systemConfigUpdated := ActionDefinition{
|
||||
Code: constants.AuditActionSystemConfigUpdated, Name: "更新受控系统配置",
|
||||
Category: constants.AuditCategoryConfiguration, Risk: constants.AuditRiskHigh,
|
||||
PrimaryResource: constants.AuditResourceSystemConfig, AllowedActor: constants.AuditActorAccount,
|
||||
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
|
||||
DefaultVisibility: constants.AuditSubjectInternalOnly,
|
||||
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
|
||||
}
|
||||
outboxReplayed := outboxRecoveryAction(
|
||||
constants.AuditActionOutboxReplayed,
|
||||
"人工重放 Outbox 事件",
|
||||
)
|
||||
outboxExpiredLeaseReleased := outboxRecoveryAction(
|
||||
constants.AuditActionOutboxExpiredLeaseReleased,
|
||||
"人工释放 Outbox 过期租约",
|
||||
)
|
||||
deviceBatchCompleted := deviceBatchAction(
|
||||
constants.AuditActionDeviceBatchAllocationCompleted,
|
||||
"完成设备批量分配",
|
||||
constants.AuditResourceDeviceBatchTask,
|
||||
)
|
||||
deviceBatchItem := deviceBatchAction(
|
||||
constants.AuditActionDeviceBatchAllocationItem,
|
||||
"处理设备批量分配项",
|
||||
constants.AuditResourceDevice,
|
||||
)
|
||||
deviceBatchItem.AllowedVisibility = []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult}
|
||||
wecomCredentialsRead := ActionDefinition{
|
||||
Code: constants.AuditActionWeComCredentialsRead, Name: "读取企业微信应用明文凭据",
|
||||
Category: constants.AuditCategorySecurity, Risk: constants.AuditRiskHigh,
|
||||
PrimaryResource: constants.AuditResourceWeComApplication, AllowedActor: constants.AuditActorAccount,
|
||||
Source: constants.AuditSourceAdminAPI, DefaultVisibility: constants.AuditSubjectInternalOnly,
|
||||
AllowedVisibility: []string{constants.AuditSubjectInternalOnly}, SensitiveRead: true,
|
||||
}
|
||||
roleCreated := accessAction(constants.AuditActionRoleCreated, "创建角色", constants.AuditResourceRole)
|
||||
roleUpdated := accessAction(constants.AuditActionRoleUpdated, "更新角色", constants.AuditResourceRole)
|
||||
roleStatusUpdated := accessAction(constants.AuditActionRoleStatusUpdated, "更新角色状态", constants.AuditResourceRole)
|
||||
roleDefaultCreditUpdated := accessAction(constants.AuditActionRoleDefaultCreditUpdated, "更新角色默认信用额度", constants.AuditResourceRole)
|
||||
roleDeleted := accessAction(constants.AuditActionRoleDeleted, "删除角色", constants.AuditResourceRole)
|
||||
rolePermissionsAssigned := accessAction(constants.AuditActionRolePermissionsAssigned, "配置角色权限", constants.AuditResourceRole)
|
||||
rolePermissionRemoved := accessAction(constants.AuditActionRolePermissionRemoved, "移除角色权限", constants.AuditResourceRole)
|
||||
rolePermissionsBatchRemoved := accessAction(constants.AuditActionRolePermissionsBatchRemoved, "批量移除角色权限", constants.AuditResourceRole)
|
||||
permissionCreated := accessAction(constants.AuditActionPermissionCreated, "创建权限", constants.AuditResourcePermission)
|
||||
permissionUpdated := accessAction(constants.AuditActionPermissionUpdated, "更新权限", constants.AuditResourcePermission)
|
||||
permissionDeleted := accessAction(constants.AuditActionPermissionDeleted, "删除权限", constants.AuditResourcePermission)
|
||||
return &Registry{
|
||||
actionsByOperation: map[string]ActionDefinition{
|
||||
constants.AuditOperationSystemConfigUpdate: systemConfigUpdated,
|
||||
constants.AuditOperationOutboxReplay: outboxReplayed,
|
||||
constants.AuditOperationOutboxReleaseExpiredLease: outboxExpiredLeaseReleased,
|
||||
},
|
||||
actionsByCode: map[string]ActionDefinition{
|
||||
constants.AuditActionAccountCreated: accountCreated,
|
||||
constants.AuditActionAccountUpdated: accountUpdated,
|
||||
constants.AuditActionAccountDeleted: accountDeleted,
|
||||
constants.AuditActionAccountPasswordReset: accountPasswordReset,
|
||||
constants.AuditActionAccountPasswordChanged: accountPasswordChanged,
|
||||
constants.AuditActionAccountWeComBound: accountWeComBound,
|
||||
constants.AuditActionAuthLogin: authLogin,
|
||||
constants.AuditActionAuthLogout: authLogout,
|
||||
constants.AuditActionAuthTokenRefreshed: authTokenRefreshed,
|
||||
constants.AuditActionAccountRolesAssigned: accountRolesAssigned,
|
||||
constants.AuditActionAccountRoleRemoved: accountRoleRemoved,
|
||||
constants.AuditActionShopRolesAssigned: shopRolesAssigned,
|
||||
constants.AuditActionShopRoleDeleted: shopRoleDeleted,
|
||||
constants.AuditActionShopCreated: shopCreated,
|
||||
constants.AuditActionShopUpdated: shopUpdated,
|
||||
constants.AuditActionShopEnabled: shopEnabled,
|
||||
constants.AuditActionShopDisabled: shopDisabled,
|
||||
constants.AuditActionShopDeleted: shopDeleted,
|
||||
constants.AuditActionShopBusinessOwnerUpdated: shopBusinessOwnerUpdated,
|
||||
constants.AuditActionShopClientLoginLimitUpdated: shopClientLoginLimitUpdated,
|
||||
constants.AuditActionEnterpriseCreated: enterpriseCreated,
|
||||
constants.AuditActionEnterpriseUpdated: enterpriseUpdated,
|
||||
constants.AuditActionEnterpriseStatusUpdated: enterpriseStatusUpdated,
|
||||
constants.AuditActionEnterprisePasswordUpdated: enterprisePasswordUpdated,
|
||||
constants.AuditActionEnterpriseCardsAllocated: enterpriseCardsAllocated,
|
||||
constants.AuditActionEnterpriseCardsRecalled: enterpriseCardsRecalled,
|
||||
constants.AuditActionEnterpriseCardRemarkUpdated: enterpriseCardRemarkUpdated,
|
||||
constants.AuditActionEnterpriseDevicesAllocated: enterpriseDevicesAllocated,
|
||||
constants.AuditActionEnterpriseDevicesRecalled: enterpriseDevicesRecalled,
|
||||
constants.AuditActionPersonalCustomerProfileUpdated: personalProfileUpdated,
|
||||
constants.AuditActionPersonalCustomerPhoneBound: personalPhoneBound,
|
||||
constants.AuditActionPersonalCustomerPhoneChanged: personalPhoneChanged,
|
||||
constants.AuditActionPersonalCustomerWechatIdentityUpdated: personalWechatIdentityUpdated,
|
||||
constants.AuditActionSystemConfigUpdated: systemConfigUpdated,
|
||||
constants.AuditActionOutboxReplayed: outboxReplayed,
|
||||
constants.AuditActionOutboxExpiredLeaseReleased: outboxExpiredLeaseReleased,
|
||||
constants.AuditActionDeviceBatchAllocationCompleted: deviceBatchCompleted,
|
||||
constants.AuditActionDeviceBatchAllocationItem: deviceBatchItem,
|
||||
constants.AuditActionWeComCredentialsRead: wecomCredentialsRead,
|
||||
constants.AuditActionRoleCreated: roleCreated,
|
||||
constants.AuditActionRoleUpdated: roleUpdated,
|
||||
constants.AuditActionRoleStatusUpdated: roleStatusUpdated,
|
||||
constants.AuditActionRoleDefaultCreditUpdated: roleDefaultCreditUpdated,
|
||||
constants.AuditActionRoleDeleted: roleDeleted,
|
||||
constants.AuditActionRolePermissionsAssigned: rolePermissionsAssigned,
|
||||
constants.AuditActionRolePermissionRemoved: rolePermissionRemoved,
|
||||
constants.AuditActionRolePermissionsBatchRemoved: rolePermissionsBatchRemoved,
|
||||
constants.AuditActionPermissionCreated: permissionCreated,
|
||||
constants.AuditActionPermissionUpdated: permissionUpdated,
|
||||
constants.AuditActionPermissionDeleted: permissionDeleted,
|
||||
},
|
||||
resources: map[string]ResourceDefinition{
|
||||
constants.AuditResourceAccount: {
|
||||
Type: constants.AuditResourceAccount, Name: "账号",
|
||||
IdentityFields: []string{"id", "username", "phone", "user_type", "shop_id", "enterprise_id", "wecom_userid", "wecom_name"},
|
||||
},
|
||||
constants.AuditResourceRole: {
|
||||
Type: constants.AuditResourceRole, Name: "角色",
|
||||
IdentityFields: []string{"id", "role_name", "role_type", "status", "default_credit_enabled", "default_credit_limit"},
|
||||
},
|
||||
constants.AuditResourcePermission: {
|
||||
Type: constants.AuditResourcePermission, Name: "权限",
|
||||
IdentityFields: []string{"id", "perm_name", "perm_code", "perm_type", "platform", "available_for_role_types", "parent_id", "status"},
|
||||
},
|
||||
constants.AuditResourceSystemConfig: {
|
||||
Type: constants.AuditResourceSystemConfig, Name: "受控系统配置",
|
||||
IdentityFields: []string{"config_key", "module"},
|
||||
},
|
||||
constants.AuditResourceOutboxEvent: {
|
||||
Type: constants.AuditResourceOutboxEvent, Name: "Outbox 事件",
|
||||
IdentityFields: []string{
|
||||
"event_id", "event_type", "aggregate_type", "aggregate_id",
|
||||
"resource_type", "resource_id", "business_key",
|
||||
},
|
||||
},
|
||||
constants.AuditResourceDeviceBatchTask: {
|
||||
Type: constants.AuditResourceDeviceBatchTask, Name: "设备批量分配任务",
|
||||
IdentityFields: []string{"task_no", "operation_type"},
|
||||
},
|
||||
constants.AuditResourceDevice: {
|
||||
Type: constants.AuditResourceDevice, Name: "设备",
|
||||
IdentityFields: []string{"id", "virtual_no", "imei", "sn", "generation"},
|
||||
},
|
||||
constants.AuditResourceIotCard: {
|
||||
Type: constants.AuditResourceIotCard, Name: "IoT卡",
|
||||
IdentityFields: []string{"id", "iccid", "iccid_19", "iccid_20", "virtual_no", "msisdn", "carrier_type", "shop_id", "series_id", "generation"},
|
||||
},
|
||||
constants.AuditResourceShop: {
|
||||
Type: constants.AuditResourceShop, Name: "店铺",
|
||||
IdentityFields: []string{"id", "shop_code", "shop_name", "parent_id", "level"},
|
||||
},
|
||||
constants.AuditResourceOrder: {
|
||||
Type: constants.AuditResourceOrder, Name: "订单",
|
||||
IdentityFields: []string{"id", "order_no", "buyer_type", "buyer_id", "asset_identifier", "total_amount", "payment_method", "payment_status"},
|
||||
},
|
||||
constants.AuditResourceRefund: {
|
||||
Type: constants.AuditResourceRefund, Name: "退款单",
|
||||
IdentityFields: []string{"id", "refund_no", "order_id", "order_no", "asset_identifier", "shop_id", "requested_refund_amount", "status"},
|
||||
},
|
||||
constants.AuditResourceEnterprise: {
|
||||
Type: constants.AuditResourceEnterprise, Name: "企业",
|
||||
IdentityFields: []string{"id", "enterprise_code", "enterprise_name", "owner_shop_id"},
|
||||
},
|
||||
constants.AuditResourceDeviceSIMBinding: {
|
||||
Type: constants.AuditResourceDeviceSIMBinding, Name: "设备卡槽绑定",
|
||||
IdentityFields: []string{"id", "device_id", "device_virtual_no", "slot_position", "iot_card_id", "iccid", "virtual_no", "is_current"},
|
||||
},
|
||||
constants.AuditResourceAssetAllocationRecord: {
|
||||
Type: constants.AuditResourceAssetAllocationRecord, Name: "资产分配记录",
|
||||
IdentityFields: []string{"id", "allocation_no", "asset_type", "asset_id", "asset_identifier", "from_owner_type", "from_owner_id", "to_owner_type", "to_owner_id"},
|
||||
},
|
||||
constants.AuditResourceExchangeOrder: {
|
||||
Type: constants.AuditResourceExchangeOrder, Name: "换货单",
|
||||
IdentityFields: []string{"id", "exchange_no", "old_asset_type", "old_asset_id", "new_asset_type", "new_asset_id", "shop_id", "status"},
|
||||
},
|
||||
constants.AuditResourceAgentRecharge: {
|
||||
Type: constants.AuditResourceAgentRecharge, Name: "代理充值单",
|
||||
IdentityFields: []string{"id", "recharge_no", "shop_id", "agent_wallet_id", "approval_instance_id", "status"},
|
||||
},
|
||||
constants.AuditResourceAssetWallet: {
|
||||
Type: constants.AuditResourceAssetWallet, Name: "资产钱包",
|
||||
IdentityFields: []string{"id", "resource_type", "resource_id", "currency"},
|
||||
},
|
||||
constants.AuditResourceApprovalInstance: {
|
||||
Type: constants.AuditResourceApprovalInstance, Name: "审批实例",
|
||||
IdentityFields: []string{"id", "business_type", "business_id", "provider", "external_ref", "status"},
|
||||
},
|
||||
constants.AuditResourceWeComApplication: {
|
||||
Type: constants.AuditResourceWeComApplication, Name: "企业微信应用配置",
|
||||
IdentityFields: []string{"id", "corp_id", "agent_id", "name", "status", "credentials_configured"},
|
||||
},
|
||||
constants.AuditResourceAuthentication: {
|
||||
Type: constants.AuditResourceAuthentication, Name: "认证状态",
|
||||
IdentityFields: []string{"account_id", "device", "auth_method", "state", "wecom_corp_id", "wecom_userid", "wecom_name"},
|
||||
},
|
||||
constants.AuditResourceEnterpriseCardAuthorization: {
|
||||
Type: constants.AuditResourceEnterpriseCardAuthorization, Name: "企业卡授权记录",
|
||||
IdentityFields: []string{"id", "enterprise_id", "card_id", "authorized_by", "authorizer_type", "authorized_at", "revoked_by", "revoked_at", "device_auth_id"},
|
||||
},
|
||||
constants.AuditResourceEnterpriseDeviceAuthorization: {
|
||||
Type: constants.AuditResourceEnterpriseDeviceAuthorization, Name: "企业设备授权记录",
|
||||
IdentityFields: []string{"id", "enterprise_id", "device_id", "authorized_by", "authorizer_type", "authorized_at", "revoked_by", "revoked_at"},
|
||||
},
|
||||
constants.AuditResourcePersonalCustomer: {
|
||||
Type: constants.AuditResourcePersonalCustomer, Name: "个人客户",
|
||||
IdentityFields: []string{"id", "nickname", "wx_open_id", "wx_union_id", "status"},
|
||||
},
|
||||
constants.AuditResourcePersonalCustomerPhone: {
|
||||
Type: constants.AuditResourcePersonalCustomerPhone, Name: "个人客户手机号",
|
||||
IdentityFields: []string{"id", "customer_id", "phone", "is_primary", "verified_at", "status"},
|
||||
},
|
||||
constants.AuditResourcePersonalCustomerOpenID: {
|
||||
Type: constants.AuditResourcePersonalCustomerOpenID, Name: "个人客户微信主体",
|
||||
IdentityFields: []string{"id", "customer_id", "app_id", "open_id", "union_id", "app_type"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func accountSecurityAction(code, name, risk string) ActionDefinition {
|
||||
return ActionDefinition{
|
||||
Code: code, Name: name, Category: constants.AuditCategorySecurity, Risk: risk,
|
||||
PrimaryResource: constants.AuditResourceAccount, AllowedActor: constants.AuditActorAccount,
|
||||
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
|
||||
DefaultVisibility: constants.AuditSubjectInternalOnly,
|
||||
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
|
||||
}
|
||||
}
|
||||
|
||||
func accessAction(code, name, primaryResource string) ActionDefinition {
|
||||
return ActionDefinition{
|
||||
Code: code, Name: name, Category: constants.AuditCategorySecurity, Risk: constants.AuditRiskHigh,
|
||||
PrimaryResource: primaryResource, AllowedActor: constants.AuditActorAccount,
|
||||
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
|
||||
DefaultVisibility: constants.AuditSubjectInternalOnly,
|
||||
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
|
||||
}
|
||||
}
|
||||
|
||||
func shopIdentityAction(code, name string) ActionDefinition {
|
||||
return ActionDefinition{
|
||||
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal,
|
||||
PrimaryResource: constants.AuditResourceShop, AllowedActor: constants.AuditActorAccount,
|
||||
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
|
||||
DefaultVisibility: constants.AuditSubjectInternalOnly,
|
||||
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
|
||||
}
|
||||
}
|
||||
|
||||
func shopStateAction(code, name, risk string) ActionDefinition {
|
||||
return ActionDefinition{
|
||||
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: risk,
|
||||
PrimaryResource: constants.AuditResourceShop, AllowedActor: constants.AuditActorAccount,
|
||||
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
|
||||
DefaultVisibility: constants.AuditSubjectResult,
|
||||
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
|
||||
}
|
||||
}
|
||||
|
||||
func enterpriseAction(code, name, category, risk string) ActionDefinition {
|
||||
return ActionDefinition{
|
||||
Code: code, Name: name, Category: category, Risk: risk,
|
||||
PrimaryResource: constants.AuditResourceEnterprise, AllowedActor: constants.AuditActorAccount,
|
||||
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
|
||||
DefaultVisibility: constants.AuditSubjectInternalOnly,
|
||||
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
|
||||
}
|
||||
}
|
||||
|
||||
func enterpriseCardAction(code, name string) ActionDefinition {
|
||||
return ActionDefinition{
|
||||
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
|
||||
PrimaryResource: constants.AuditResourceEnterprise, AllowedActor: constants.AuditActorAccount,
|
||||
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
|
||||
DefaultVisibility: constants.AuditSubjectInternalOnly,
|
||||
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
|
||||
}
|
||||
}
|
||||
|
||||
func personalAction(code, name string, subjectFields []string) ActionDefinition {
|
||||
return ActionDefinition{
|
||||
Code: code, Name: name, Category: constants.AuditCategoryIdentity, Risk: constants.AuditRiskNormal,
|
||||
PrimaryResource: constants.AuditResourcePersonalCustomer, AllowedActor: constants.AuditActorPersonalCustomer,
|
||||
Source: constants.AuditSourcePersonalAPI, RequireTransaction: true,
|
||||
DefaultVisibility: constants.AuditSubjectDetail,
|
||||
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectDetail},
|
||||
SubjectFields: subjectFields,
|
||||
}
|
||||
}
|
||||
|
||||
func accountLifecycleAction(code, name, risk string) ActionDefinition {
|
||||
return ActionDefinition{
|
||||
Code: code, Name: name, Category: constants.AuditCategoryIdentity, Risk: risk,
|
||||
PrimaryResource: constants.AuditResourceAccount, AllowedActor: constants.AuditActorAccount,
|
||||
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
|
||||
DefaultVisibility: constants.AuditSubjectInternalOnly,
|
||||
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
|
||||
}
|
||||
}
|
||||
|
||||
func deviceBatchAction(code, name, primaryResource string) ActionDefinition {
|
||||
return ActionDefinition{
|
||||
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
|
||||
PrimaryResource: primaryResource, AllowedActor: constants.AuditActorSystemTask,
|
||||
Source: constants.AuditSourceWorker, RequireTransaction: true,
|
||||
DefaultVisibility: constants.AuditSubjectInternalOnly,
|
||||
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
|
||||
}
|
||||
}
|
||||
|
||||
func outboxRecoveryAction(code, name string) ActionDefinition {
|
||||
return ActionDefinition{
|
||||
Code: code, Name: name, Category: constants.AuditCategoryReliability, Risk: constants.AuditRiskHigh,
|
||||
PrimaryResource: constants.AuditResourceOutboxEvent, AllowedActor: constants.AuditActorAccount,
|
||||
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
|
||||
DefaultVisibility: constants.AuditSubjectInternalOnly,
|
||||
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
|
||||
}
|
||||
}
|
||||
|
||||
// Action 返回已注册动作定义。
|
||||
func (r *Registry) Action(code string) (ActionDefinition, bool) {
|
||||
if r == nil {
|
||||
return ActionDefinition{}, false
|
||||
}
|
||||
action, ok := r.actionsByCode[code]
|
||||
return action, ok
|
||||
}
|
||||
|
||||
// ActionByOperation 返回旧应用接缝操作类型对应的受控动作。
|
||||
func (r *Registry) ActionByOperation(operation string) (ActionDefinition, bool) {
|
||||
if r == nil {
|
||||
return ActionDefinition{}, false
|
||||
}
|
||||
action, ok := r.actionsByOperation[operation]
|
||||
return action, ok
|
||||
}
|
||||
|
||||
// Resource 返回已注册资源定义。
|
||||
func (r *Registry) Resource(resourceType string) (ResourceDefinition, bool) {
|
||||
if r == nil {
|
||||
return ResourceDefinition{}, false
|
||||
}
|
||||
resource, ok := r.resources[resourceType]
|
||||
return resource, ok
|
||||
}
|
||||
57
internal/infrastructure/audit/security_test.go
Normal file
57
internal/infrastructure/audit/security_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// TestSecurityActionsRegistered 验证账号安全动作不会绕过注册表。
|
||||
func TestSecurityActionsRegistered(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
for _, code := range []string{
|
||||
constants.AuditActionAccountPasswordReset,
|
||||
constants.AuditActionAccountPasswordChanged,
|
||||
constants.AuditActionAccountWeComBound,
|
||||
constants.AuditActionAuthLogin,
|
||||
constants.AuditActionAuthLogout,
|
||||
constants.AuditActionAuthTokenRefreshed,
|
||||
} {
|
||||
action, ok := registry.Action(code)
|
||||
if !ok {
|
||||
t.Fatalf("安全动作未注册:%s", code)
|
||||
}
|
||||
if action.PrimaryResource != constants.AuditResourceAccount || action.Category != constants.AuditCategorySecurity {
|
||||
t.Fatalf("安全动作注册错误:%s", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityAuditRemovesCredentials 验证安全凭据不会进入审计 JSON。
|
||||
func TestSecurityAuditRemovesCredentials(t *testing.T) {
|
||||
encoded, err := safeObject(map[string]any{
|
||||
"password": "secret",
|
||||
"verification_code": "123456",
|
||||
"access_token": "token",
|
||||
"cookie": "session=value",
|
||||
"credentials_configured": true,
|
||||
"state": "changed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("清理审计 JSON 失败:%v", err)
|
||||
}
|
||||
var value map[string]any
|
||||
if err := sonic.Unmarshal(encoded, &value); err != nil {
|
||||
t.Fatalf("解析审计 JSON 失败:%v", err)
|
||||
}
|
||||
for _, field := range []string{"password", "verification_code", "access_token", "cookie"} {
|
||||
if _, exists := value[field]; exists {
|
||||
t.Fatalf("安全凭据未删除:%s", field)
|
||||
}
|
||||
}
|
||||
if value["credentials_configured"] != true || value["state"] != "changed" {
|
||||
t.Fatalf("安全业务事实被错误删除:%v", value)
|
||||
}
|
||||
}
|
||||
1098
internal/infrastructure/audit/writer.go
Normal file
1098
internal/infrastructure/audit/writer.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/datatypes"
|
||||
@@ -46,15 +47,18 @@ type Attempt struct {
|
||||
|
||||
// Completion 描述外部尝试从待处理状态进入终态的结果。
|
||||
type Completion struct {
|
||||
Result string
|
||||
HTTPStatus int
|
||||
ProviderCode string
|
||||
ProviderMessage string
|
||||
ResponseSummary any
|
||||
DurationMS int64
|
||||
StateChanged bool
|
||||
AuditEventID *uint
|
||||
RecoveryStrategy string
|
||||
Result string
|
||||
HTTPStatus int
|
||||
ProviderCode string
|
||||
ProviderMessage string
|
||||
SafeProviderMessage string
|
||||
ResponseSummary any
|
||||
DurationMS int64
|
||||
StateChanged bool
|
||||
ResourceID *string
|
||||
ResourceKey *string
|
||||
AuditEventID *uint
|
||||
RecoveryStrategy string
|
||||
}
|
||||
|
||||
// InboundAttempt 描述业务处理前必须保存的入站回调安全事实。
|
||||
@@ -105,6 +109,13 @@ func (r *Repository) Start(ctx context.Context, input Attempt) (*model.Integrati
|
||||
}
|
||||
if input.Attempt <= 0 {
|
||||
input.Attempt = 1
|
||||
if input.TriggerSeries != nil {
|
||||
if err := r.db.WithContext(ctx).Model(&model.IntegrationLog{}).
|
||||
Select("COALESCE(MAX(attempt), 0) + 1").
|
||||
Where("trigger_series = ?", *input.TriggerSeries).Scan(&input.Attempt).Error; err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "计算 Integration Log 尝试序号失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
if input.StartedAt == nil {
|
||||
startedAt := r.now().UTC()
|
||||
@@ -136,12 +147,19 @@ func (r *Repository) Complete(ctx context.Context, integrationID string, complet
|
||||
if r == nil || r.db == nil {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
|
||||
}
|
||||
if integrationID == "" || !isTerminalResult(completion.Result) {
|
||||
if !validRequiredString(integrationID, constants.IntegrationIDMaxLength) ||
|
||||
!validOptionalString(completion.ResourceID, constants.IntegrationResourceIDMaxLength) ||
|
||||
!validOptionalString(completion.ResourceKey, constants.IntegrationResourceKeyMaxLength) ||
|
||||
!isTerminalResult(completion.Result) {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 终态参数无效")
|
||||
}
|
||||
if completion.Result == constants.IntegrationResultUnknown && strings.TrimSpace(completion.RecoveryStrategy) == "" {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "结果未知必须记录明确恢复策略")
|
||||
}
|
||||
safeProviderMessage := strings.TrimSpace(completion.SafeProviderMessage)
|
||||
if safeProviderMessage != "" && utf8.RuneCountInString(constants.IntegrationSafeMessagePrefix+safeProviderMessage) > constants.IntegrationProviderMessageMaxLength {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 安全结果摘要过长")
|
||||
}
|
||||
responseSummary, err := marshalSummary(completion.ResponseSummary)
|
||||
if err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "Integration Log 响应摘要无效")
|
||||
@@ -157,12 +175,20 @@ func (r *Repository) Complete(ctx context.Context, integrationID string, complet
|
||||
if completion.ProviderCode != "" {
|
||||
updates["provider_code"] = completion.ProviderCode
|
||||
}
|
||||
if completion.ProviderMessage != "" {
|
||||
if safeProviderMessage != "" {
|
||||
updates["provider_message"] = constants.IntegrationSafeMessagePrefix + safeProviderMessage
|
||||
} else if completion.ProviderMessage != "" {
|
||||
updates["provider_message"] = sanitizer.TextSummary(completion.ProviderMessage)
|
||||
}
|
||||
if completion.AuditEventID != nil {
|
||||
updates["audit_event_id"] = completion.AuditEventID
|
||||
}
|
||||
if completion.ResourceID != nil {
|
||||
updates["resource_id"] = completion.ResourceID
|
||||
}
|
||||
if completion.ResourceKey != nil {
|
||||
updates["resource_key"] = completion.ResourceKey
|
||||
}
|
||||
if completion.RecoveryStrategy != "" {
|
||||
updates["recovery_strategy"] = completion.RecoveryStrategy
|
||||
}
|
||||
@@ -187,12 +213,17 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*
|
||||
if r == nil || r.db == nil {
|
||||
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
|
||||
}
|
||||
if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" {
|
||||
if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" ||
|
||||
!validGeneratedString(input.IntegrationID, constants.IntegrationIDMaxLength) ||
|
||||
!validOptionalString(input.ResourceID, constants.IntegrationResourceIDMaxLength) ||
|
||||
!validOptionalString(input.ResourceKey, constants.IntegrationResourceKeyMaxLength) ||
|
||||
!validOptionalString(input.CorrelationID, constants.IntegrationCorrelationIDMaxLength) {
|
||||
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidParam, "入站 Integration Log 参数无效")
|
||||
}
|
||||
if input.IntegrationID == "" {
|
||||
input.IntegrationID = uuid.NewString()
|
||||
}
|
||||
triggerSeries := input.IntegrationID
|
||||
hash := sha256.Sum256(input.RawPayload)
|
||||
summary, err := marshalSummary(map[string]any{
|
||||
"content_type": input.ContentType,
|
||||
@@ -208,7 +239,8 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*
|
||||
Provider: input.Provider, Direction: constants.IntegrationDirectionInbound, Operation: input.Operation,
|
||||
ExternalID: optionalString(input.ExternalID), ResourceType: optionalString(input.ResourceType),
|
||||
ResourceID: input.ResourceID, ResourceKey: input.ResourceKey, StartedAt: &now, Attempt: 1,
|
||||
Result: constants.IntegrationResultPending, RequestSummary: summary,
|
||||
TriggerSeries: &triggerSeries,
|
||||
Result: constants.IntegrationResultPending, RequestSummary: summary,
|
||||
ContentHash: hex.EncodeToString(hash[:]), RequestID: input.RequestID, CorrelationID: input.CorrelationID,
|
||||
}
|
||||
result := r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
@@ -289,9 +321,28 @@ func validateAttempt(input Attempt) error {
|
||||
if input.InitialResult != "" && input.InitialResult != constants.IntegrationResultPending && !isUnsentResult(input.InitialResult) {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 初始结果只能是待处理或未发送终态")
|
||||
}
|
||||
if !validGeneratedString(input.IntegrationID, constants.IntegrationIDMaxLength) ||
|
||||
!validOptionalString(input.TriggerSeries, constants.IntegrationTriggerSeriesMaxLength) ||
|
||||
!validOptionalString(input.CorrelationID, constants.IntegrationCorrelationIDMaxLength) ||
|
||||
!validOptionalString(input.ResourceID, constants.IntegrationResourceIDMaxLength) ||
|
||||
!validOptionalString(input.ResourceKey, constants.IntegrationResourceKeyMaxLength) {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 链路或资源标识无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validGeneratedString(value string, maxLength int) bool {
|
||||
return value == "" || validRequiredString(value, maxLength)
|
||||
}
|
||||
|
||||
func validRequiredString(value string, maxLength int) bool {
|
||||
return strings.TrimSpace(value) == value && value != "" && utf8.RuneCountInString(value) <= maxLength
|
||||
}
|
||||
|
||||
func validOptionalString(value *string, maxLength int) bool {
|
||||
return value == nil || validRequiredString(*value, maxLength)
|
||||
}
|
||||
|
||||
func isTerminalResult(result string) bool {
|
||||
switch result {
|
||||
case constants.IntegrationResultSuccess, constants.IntegrationResultFailed, constants.IntegrationResultUnknown,
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,7 @@ type DeliveryEnvelope struct {
|
||||
BusinessKey string `json:"business_key,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
ParentEventID string `json:"parent_event_id,omitempty"`
|
||||
Payload sonic.NoCopyRawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
@@ -253,7 +255,7 @@ func deliveryEnvelope(event model.OutboxEvent) DeliveryEnvelope {
|
||||
EventID: event.EventID, EventType: event.EventType, PayloadVersion: event.PayloadVersion,
|
||||
AggregateType: event.AggregateType, AggregateID: event.AggregateID,
|
||||
ResourceType: event.ResourceType, ResourceID: event.ResourceID, BusinessKey: event.BusinessKey,
|
||||
RequestID: event.RequestID, CorrelationID: event.CorrelationID,
|
||||
RequestID: event.RequestID, CorrelationID: event.CorrelationID, ParentEventID: event.ParentEventID,
|
||||
Payload: sonic.NoCopyRawMessage(event.Payload),
|
||||
}
|
||||
}
|
||||
@@ -326,5 +328,10 @@ func (h *Handler) Handle(ctx context.Context, task *asynq.Task) error {
|
||||
if envelope.EventID == "" || envelope.EventType == "" || envelope.PayloadVersion <= 0 {
|
||||
return stderrors.New("Outbox 事件信封不完整")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: envelope.EventType,
|
||||
ActorName: "Outbox 消费任务", Source: constants.AuditSourceWorker,
|
||||
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, ParentEventID: envelope.ParentEventID,
|
||||
})
|
||||
return h.consumer.Consume(ctx, envelope)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/asynctask"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
@@ -30,6 +31,7 @@ type Envelope struct {
|
||||
BusinessKey string `json:"business_key,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
ParentEventID string `json:"parent_event_id,omitempty"`
|
||||
Payload any `json:"payload"`
|
||||
}
|
||||
|
||||
@@ -73,13 +75,24 @@ func (r *Repository) append(ctx context.Context, tx *gorm.DB, envelope Envelope,
|
||||
if envelope.PayloadVersion <= 0 {
|
||||
envelope.PayloadVersion = 1
|
||||
}
|
||||
linkage := auditcontext.From(ctx)
|
||||
if envelope.RequestID == "" {
|
||||
envelope.RequestID = linkage.RequestID
|
||||
}
|
||||
if envelope.CorrelationID == "" {
|
||||
envelope.CorrelationID = linkage.CorrelationID
|
||||
}
|
||||
if envelope.ParentEventID == "" {
|
||||
envelope.ParentEventID = linkage.ParentEventID
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
event := &model.OutboxEvent{
|
||||
EventID: envelope.EventID, EventType: envelope.EventType, PayloadVersion: envelope.PayloadVersion,
|
||||
AggregateType: envelope.AggregateType, AggregateID: envelope.AggregateID,
|
||||
ResourceType: envelope.ResourceType, ResourceID: envelope.ResourceID, BusinessKey: envelope.BusinessKey,
|
||||
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, Payload: datatypes.JSON(payload),
|
||||
Status: constants.OutboxStatusPending, MaxRetries: constants.OutboxDefaultMaxRetries, NextAttemptAt: now,
|
||||
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, ParentEventID: envelope.ParentEventID,
|
||||
Payload: datatypes.JSON(payload),
|
||||
Status: constants.OutboxStatusPending, MaxRetries: constants.OutboxDefaultMaxRetries, NextAttemptAt: now,
|
||||
}
|
||||
create := tx.WithContext(ctx)
|
||||
if idempotent {
|
||||
|
||||
@@ -32,7 +32,8 @@ func (w *AgentRechargePaymentEventWriter) Append(ctx context.Context, tx *gorm.D
|
||||
PayloadVersion: constants.AgentRechargePaymentConfirmedPayloadVersionV1,
|
||||
AggregateType: "agent_recharge", AggregateID: strconv.FormatUint(uint64(event.RechargeID), 10),
|
||||
ResourceType: "payment", ResourceID: strconv.FormatUint(uint64(event.PaymentID), 10),
|
||||
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
|
||||
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
|
||||
ParentEventID: event.ParentEventID, Payload: event,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"github.com/hibiken/asynq"
|
||||
|
||||
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -20,10 +22,18 @@ func NewAgentRechargeRecoveryTaskHandler(service *agentrecharge.RecoverOnlinePay
|
||||
}
|
||||
|
||||
// Handle 扫描长期待支付记录并复用原支付单号收敛渠道状态。
|
||||
func (h *AgentRechargeRecoveryTaskHandler) Handle(ctx context.Context, _ *asynq.Task) error {
|
||||
func (h *AgentRechargeRecoveryTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
|
||||
if h == nil || h.service == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复任务未配置")
|
||||
}
|
||||
taskType := constants.TaskTypeAgentRechargeRecovery
|
||||
if task != nil && task.Type() != "" {
|
||||
taskType = task.Type()
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
ActorKind: constants.AuditActorScheduledJob, ActorID: taskType,
|
||||
ActorName: "代理在线充值支付恢复计划任务", Source: constants.AuditSourceScheduler,
|
||||
})
|
||||
_, err := h.service.ProcessBatch(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ func (a *AlipayWapAdapter) completeUnknown(ctx context.Context, integrationID st
|
||||
a.logger.Warn("支付宝支付请求结果未知", zap.String("integration_id", integrationID), zap.Error(cause))
|
||||
}
|
||||
_, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: "支付宝支付请求结果未知",
|
||||
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", SafeProviderMessage: "支付宝支付请求结果未知",
|
||||
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
|
||||
RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
|
||||
})
|
||||
|
||||
@@ -184,7 +184,7 @@ func (a *WechatWebAdapter) completeUnknown(ctx context.Context, integrationID st
|
||||
a.logger.Warn("微信支付请求结果未知", zap.String("integration_id", integrationID), zap.Error(cause))
|
||||
}
|
||||
_, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: "微信支付请求结果未知",
|
||||
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", SafeProviderMessage: "微信支付请求结果未知",
|
||||
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
|
||||
RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
|
||||
})
|
||||
|
||||
@@ -92,10 +92,13 @@ func (u *ApprovalAttachmentUploader) Upload(ctx context.Context, applicationID,
|
||||
return "", err
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(instanceID), 10)
|
||||
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(nil)
|
||||
attempt, err := u.integration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
IntegrationID: integrationID,
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: constants.IntegrationOperationWeComAttachmentUpload, ResourceType: constants.WeComApprovalInstanceResourceType,
|
||||
ResourceID: &resourceID, RequestSummary: map[string]any{
|
||||
ResourceID: &resourceID, TriggerSeries: triggerSeries, CorrelationID: correlationID,
|
||||
RequestSummary: map[string]any{
|
||||
"application_id": applicationID, "file_name": fileName, "file_bytes": info.Size(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ func NewApprovalDetailClient(tokens DirectoryTokenProvider, integration TokenInt
|
||||
}
|
||||
|
||||
// Get 获取审批详情并记录一次真实外呼。
|
||||
func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo string) (ApprovalDetail, error) {
|
||||
func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo string, resourceID *string, correlationID string) (ApprovalDetail, error) {
|
||||
token, err := c.tokens.GetAccessToken(ctx, applicationID)
|
||||
if err != nil {
|
||||
return ApprovalDetail{}, err
|
||||
@@ -51,11 +51,14 @@ func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo
|
||||
if err != nil {
|
||||
return ApprovalDetail{}, err
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(applicationID), 10)
|
||||
resourceKey := strings.TrimSpace(spNo)
|
||||
triggerSeries := "wecom-approval-detail:" + resourceKey
|
||||
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: constants.IntegrationOperationWeComApprovalDetail, ResourceType: constants.WeComApprovalInstanceResourceType,
|
||||
ResourceID: &resourceID, ExternalID: &spNo, RequestSummary: map[string]any{"application_id": applicationID, "sp_no": spNo},
|
||||
ResourceID: resourceID, ResourceKey: &resourceKey, ExternalID: &spNo,
|
||||
TriggerSeries: &triggerSeries, CorrelationID: optionalIntegrationString(correlationID),
|
||||
RequestSummary: map[string]any{"application_id": applicationID, "sp_no": spNo},
|
||||
})
|
||||
if err != nil {
|
||||
return ApprovalDetail{}, err
|
||||
|
||||
@@ -2,6 +2,7 @@ package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -39,11 +40,18 @@ func (h *ApprovalDetailTaskHandler) Handle(ctx context.Context, task *asynq.Task
|
||||
if !validApprovalSyncSource(payload.Source) {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信审批详情同步来源无效")
|
||||
}
|
||||
detail, err := h.details.Get(ctx, payload.ApplicationID, payload.SPNo)
|
||||
record, err := h.contexts.FindBySPNo(ctx, payload.ApplicationID, payload.SPNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record, err := h.contexts.FindBySPNo(ctx, payload.ApplicationID, payload.SPNo)
|
||||
correlationID := payload.SPNo
|
||||
var resourceID *string
|
||||
if record != nil {
|
||||
value := strconv.FormatUint(uint64(record.Instance.ID), 10)
|
||||
resourceID = &value
|
||||
correlationID = record.Instance.CorrelationID
|
||||
}
|
||||
detail, err := h.details.Get(ctx, payload.ApplicationID, payload.SPNo, resourceID, correlationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -68,11 +76,14 @@ func (h *ApprovalDetailTaskHandler) Handle(ctx context.Context, task *asynq.Task
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(payload.IntegrationID) != "" {
|
||||
resolvedResourceID := strconv.FormatUint(uint64(record.Instance.ID), 10)
|
||||
resolvedResourceKey := strings.TrimSpace(payload.SPNo)
|
||||
_, err = h.integration.Complete(ctx, payload.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultCompleted, ProviderCode: "processed",
|
||||
ProviderMessage: "企业微信审批回调已完成权威详情同步",
|
||||
ResponseSummary: map[string]any{"sp_no": payload.SPNo, "sp_status": detail.SPStatus, "decisions": decisions},
|
||||
DurationMS: 0, StateChanged: len(decisions) > 0,
|
||||
ResourceID: &resolvedResourceID, ResourceKey: &resolvedResourceKey,
|
||||
})
|
||||
}
|
||||
return err
|
||||
|
||||
@@ -71,14 +71,16 @@ func (c *ApprovalInfoClient) List(ctx context.Context, input ApprovalInfoQuery)
|
||||
return ApprovalInfoPage{}, err
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(input.ApplicationID), 10)
|
||||
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(nil)
|
||||
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: constants.IntegrationOperationWeComApprovalInfo, ResourceType: constants.WeComApprovalInstanceResourceType,
|
||||
IntegrationID: integrationID,
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: constants.IntegrationOperationWeComApprovalInfo, ResourceType: constants.WeComApplicationResourceType,
|
||||
ResourceID: &resourceID, RequestSummary: map[string]any{
|
||||
"application_id": input.ApplicationID, "starttime": input.StartTime.Unix(), "endtime": input.EndTime.Unix(),
|
||||
"template_id": input.TemplateID, "creator_userid": input.CreatorUserID, "size": input.Size,
|
||||
"cursor_present": strings.TrimSpace(input.Cursor) != "",
|
||||
},
|
||||
}, TriggerSeries: triggerSeries, CorrelationID: correlationID,
|
||||
})
|
||||
if err != nil {
|
||||
return ApprovalInfoPage{}, err
|
||||
|
||||
@@ -76,13 +76,14 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(input.InstanceID), 10)
|
||||
correlationID := strings.TrimSpace(input.CorrelationID)
|
||||
triggerSeries := "wecom-approval-submit:" + resourceID
|
||||
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: constants.IntegrationOperationWeComApprovalSubmit, ResourceType: constants.WeComApprovalInstanceResourceType,
|
||||
ResourceID: &resourceID, RequestSummary: map[string]any{
|
||||
"application_id": input.ApplicationID, "template_id": input.TemplateID,
|
||||
"creator_source_configured": input.CreatorUserID != "", "control_count": len(input.Contents),
|
||||
}, CorrelationID: optionalIntegrationString(correlationID),
|
||||
}, CorrelationID: optionalIntegrationString(correlationID), TriggerSeries: &triggerSeries,
|
||||
})
|
||||
if err != nil {
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "写入企业微信审批提交日志失败", SafeToRetry: true}, err
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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/queue"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -84,12 +85,13 @@ func (s *CallbackService) Receive(ctx context.Context, applicationID uint, signa
|
||||
if err := xml.Unmarshal(plaintext, &event); err != nil || event.Event != "sys_approval_change" || strings.TrimSpace(event.SPNo) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信审批回调事件无效")
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(applicationID), 10)
|
||||
resourceKey := strings.TrimSpace(event.SPNo)
|
||||
requestID := middleware.GetRequestIDFromContext(ctx)
|
||||
log, created, err := s.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
|
||||
IdempotencyKey: applicationCallbackIdempotencyKey(applicationID, signature),
|
||||
Provider: constants.IntegrationProviderWeCom, Operation: constants.IntegrationOperationWeComApprovalCallback,
|
||||
ExternalID: event.SPNo, ResourceType: constants.WeComApprovalInstanceResourceType, ResourceID: &resourceID,
|
||||
RawPayload: body, ContentType: "application/xml",
|
||||
ExternalID: event.SPNo, ResourceType: constants.WeComApprovalInstanceResourceType, ResourceKey: &resourceKey,
|
||||
RawPayload: body, ContentType: "application/xml", RequestID: requestID, CorrelationID: &resourceKey,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -75,11 +75,13 @@ func (c *DirectoryClient) listVisibleDepartments(ctx context.Context, applicatio
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(applicationID), 10)
|
||||
requestID := middleware.GetRequestIDFromContext(ctx)
|
||||
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(requestID)
|
||||
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
IntegrationID: integrationID,
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: constants.IntegrationOperationWeComVisibleDepartments, ResourceType: constants.WeComApplicationResourceType,
|
||||
ResourceID: &resourceID, RequestSummary: map[string]any{"application_id": applicationID},
|
||||
RequestID: requestID, CorrelationID: requestID,
|
||||
RequestID: requestID, CorrelationID: correlationID, TriggerSeries: triggerSeries,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -115,12 +117,14 @@ func (c *DirectoryClient) listDepartmentMembers(ctx context.Context, application
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(applicationID), 10)
|
||||
requestID := middleware.GetRequestIDFromContext(ctx)
|
||||
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(requestID)
|
||||
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
IntegrationID: integrationID,
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: constants.IntegrationOperationWeComVisibleMembers, ResourceType: constants.WeComApplicationResourceType,
|
||||
ResourceID: &resourceID, RequestSummary: map[string]any{
|
||||
"application_id": applicationID, "department_id": departmentID, "fetch_child": true,
|
||||
}, RequestID: requestID, CorrelationID: requestID,
|
||||
}, RequestID: requestID, CorrelationID: correlationID, TriggerSeries: triggerSeries,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
11
internal/infrastructure/wecom/integration_linkage.go
Normal file
11
internal/infrastructure/wecom/integration_linkage.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package wecom
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
func singleIntegrationLinkage(correlationID *string) (string, *string, *string) {
|
||||
integrationID := uuid.NewString()
|
||||
if correlationID == nil {
|
||||
correlationID = &integrationID
|
||||
}
|
||||
return integrationID, &integrationID, correlationID
|
||||
}
|
||||
@@ -54,12 +54,14 @@ func (c *TemplateClient) GetTemplateDetail(ctx context.Context, applicationID ui
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(applicationID), 10)
|
||||
requestID := middleware.GetRequestIDFromContext(ctx)
|
||||
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(requestID)
|
||||
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
IntegrationID: integrationID,
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: constants.IntegrationOperationWeComTemplateDetail, ResourceType: constants.WeComApprovalSceneResourceType,
|
||||
ResourceID: &resourceID, RequestSummary: map[string]any{
|
||||
"application_id": applicationID, "template_id": templateID,
|
||||
}, RequestID: requestID, CorrelationID: requestID,
|
||||
}, RequestID: requestID, CorrelationID: correlationID, TriggerSeries: triggerSeries,
|
||||
})
|
||||
if err != nil {
|
||||
return wecomapp.TemplateDefinition{}, err
|
||||
|
||||
@@ -161,12 +161,14 @@ func (p *TokenProvider) fetchAndCache(ctx context.Context, applicationID uint, c
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(applicationID), 10)
|
||||
requestID := middleware.GetRequestIDFromContext(ctx)
|
||||
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(requestID)
|
||||
attempt, err := p.integration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
IntegrationID: integrationID,
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: constants.IntegrationOperationWeComAccessToken, ResourceType: constants.WeComApplicationResourceType,
|
||||
ResourceID: &resourceID, RequestSummary: map[string]any{
|
||||
"application_id": applicationID, "corp_id": application.CorpID, "agent_id": application.AgentID,
|
||||
}, RequestID: requestID, CorrelationID: requestID,
|
||||
}, RequestID: requestID, CorrelationID: correlationID, TriggerSeries: triggerSeries,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
pkgmiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
@@ -245,5 +246,13 @@ func injectAgentOpenAPIContext(c *fiber.Ctx, shopStore *postgres.ShopStore, acco
|
||||
SubordinateShopIDs: shopIDs,
|
||||
}
|
||||
pkgmiddleware.SetUserToFiberContext(c, info)
|
||||
var actorShopID *uint
|
||||
if shopID > 0 {
|
||||
actorShopID = &shopID
|
||||
}
|
||||
c.SetUserContext(auditcontext.With(c.UserContext(), auditcontext.Context{
|
||||
ActorKind: constants.AuditActorOpenAPI, ActorID: strconv.FormatUint(uint64(account.ID), 10),
|
||||
ActorName: account.Username, ActorShopID: actorShopID, Source: constants.AuditSourceOpenAPI,
|
||||
}))
|
||||
return nil
|
||||
}
|
||||
|
||||
76
internal/model/audit_event.go
Normal file
76
internal/model/audit_event.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// AuditEvent 是不可变内部业务审计事件。
|
||||
type AuditEvent struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex" json:"event_id"`
|
||||
OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null" json:"occurred_at"`
|
||||
Category string `gorm:"column:category;type:varchar(64);not null" json:"category"`
|
||||
ActionCode string `gorm:"column:action_code;type:varchar(100);not null" json:"action_code"`
|
||||
ActionName string `gorm:"column:action_name;type:varchar(200);not null" json:"action_name"`
|
||||
Summary string `gorm:"column:summary;type:varchar(500);not null" json:"summary"`
|
||||
ActorKind string `gorm:"column:actor_kind;type:varchar(32);not null" json:"actor_kind"`
|
||||
ActorID string `gorm:"column:actor_id;type:varchar(128);not null" json:"actor_id"`
|
||||
ActorName string `gorm:"column:actor_name;type:varchar(200);not null" json:"actor_name"`
|
||||
ActorShopID *uint `gorm:"column:actor_shop_id" json:"actor_shop_id,omitempty"`
|
||||
ActorShopName string `gorm:"column:actor_shop_name;type:varchar(200);not null;default:''" json:"actor_shop_name,omitempty"`
|
||||
ActorEnterpriseID *uint `gorm:"column:actor_enterprise_id" json:"actor_enterprise_id,omitempty"`
|
||||
ActorEnterpriseName string `gorm:"column:actor_enterprise_name;type:varchar(200);not null;default:''" json:"actor_enterprise_name,omitempty"`
|
||||
Source string `gorm:"column:source;type:varchar(32);not null" json:"source"`
|
||||
RequestPath string `gorm:"column:request_path;type:varchar(300);not null;default:''" json:"request_path,omitempty"`
|
||||
RequestMethod string `gorm:"column:request_method;type:varchar(16);not null;default:''" json:"request_method,omitempty"`
|
||||
IPAddress string `gorm:"column:ip_address;type:varchar(64);not null;default:''" json:"ip_address,omitempty"`
|
||||
UserAgent string `gorm:"column:user_agent;type:varchar(500);not null;default:''" json:"user_agent,omitempty"`
|
||||
ScopeType string `gorm:"column:scope_type;type:varchar(32);not null" json:"scope_type"`
|
||||
ScopeID string `gorm:"column:scope_id;type:varchar(128);not null;default:''" json:"scope_id,omitempty"`
|
||||
ScopeName string `gorm:"column:scope_name;type:varchar(200);not null;default:''" json:"scope_name,omitempty"`
|
||||
Result string `gorm:"column:result;type:varchar(16);not null" json:"result"`
|
||||
RiskLevel string `gorm:"column:risk_level;type:varchar(16);not null" json:"risk_level"`
|
||||
ErrorCode string `gorm:"column:error_code;type:varchar(100);not null;default:''" json:"error_code,omitempty"`
|
||||
ErrorSummary string `gorm:"column:error_summary;type:varchar(500);not null;default:''" json:"error_summary,omitempty"`
|
||||
RequestID string `gorm:"column:request_id;type:varchar(100);not null;default:''" json:"request_id,omitempty"`
|
||||
CorrelationID string `gorm:"column:correlation_id;type:varchar(100);not null;default:''" json:"correlation_id,omitempty"`
|
||||
ParentEventID string `gorm:"column:parent_event_id;type:varchar(64);not null;default:''" json:"parent_event_id,omitempty"`
|
||||
BatchTotal int `gorm:"column:batch_total;not null;default:0" json:"batch_total"`
|
||||
SuccessCount int `gorm:"column:success_count;not null;default:0" json:"success_count"`
|
||||
FailCount int `gorm:"column:fail_count;not null;default:0" json:"fail_count"`
|
||||
Metadata datatypes.JSON `gorm:"column:metadata;type:jsonb;not null;default:'{}'" json:"metadata"`
|
||||
ContentHash string `gorm:"column:content_hash;type:varchar(64);not null" json:"content_hash"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 返回统一审计事件表名。
|
||||
func (AuditEvent) TableName() string {
|
||||
return "tb_audit_event"
|
||||
}
|
||||
|
||||
// AuditEventResource 是审计事件发生时的独立资源快照。
|
||||
type AuditEventResource struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
AuditEventID uint `gorm:"column:audit_event_id;not null" json:"audit_event_id"`
|
||||
ResourceType string `gorm:"column:resource_type;type:varchar(64);not null" json:"resource_type"`
|
||||
ResourceID *string `gorm:"column:resource_id;type:varchar(128)" json:"resource_id,omitempty"`
|
||||
ResourceKey string `gorm:"column:resource_key;type:varchar(200);not null" json:"resource_key"`
|
||||
DisplayName string `gorm:"column:display_name;type:varchar(255);not null;default:''" json:"display_name"`
|
||||
Relation string `gorm:"column:relation;type:varchar(16);not null" json:"relation"`
|
||||
Role string `gorm:"column:role;type:varchar(64);not null" json:"role"`
|
||||
IdentitySnapshot datatypes.JSON `gorm:"column:identity_snapshot;type:jsonb;not null;default:'{}'" json:"identity_snapshot"`
|
||||
BeforeData datatypes.JSON `gorm:"column:before_data;type:jsonb;not null;default:'{}'" json:"before_data"`
|
||||
AfterData datatypes.JSON `gorm:"column:after_data;type:jsonb;not null;default:'{}'" json:"after_data"`
|
||||
SubjectVisibility string `gorm:"column:subject_visibility;type:varchar(24);not null" json:"subject_visibility"`
|
||||
SubjectSummary string `gorm:"column:subject_summary;type:varchar(500);not null;default:''" json:"subject_summary,omitempty"`
|
||||
SubjectData datatypes.JSON `gorm:"column:subject_data;type:jsonb;not null;default:'{}'" json:"subject_data"`
|
||||
SortOrder int `gorm:"column:sort_order;not null;default:0" json:"sort_order"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 返回审计事件资源表名。
|
||||
func (AuditEventResource) TableName() string {
|
||||
return "tb_audit_event_resource"
|
||||
}
|
||||
@@ -19,7 +19,7 @@ type AgentRechargeOnlineResponse struct {
|
||||
RechargeSource string `json:"recharge_source" description:"充值来源 (agent_online:代理在线自充)"`
|
||||
RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"`
|
||||
Amount int64 `json:"amount" description:"在线充值金额(分),范围10000分~100000000分"`
|
||||
QRContent string `json:"qr_content" description:"支付渠道原始扫码付款内容,由前端渲染二维码"`
|
||||
QRContent string `json:"qr_content" description:"支付链接(HTTPS URL),由前端渲染二维码"`
|
||||
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
}
|
||||
|
||||
113
internal/model/dto/audit_dto.go
Normal file
113
internal/model/dto/audit_dto.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package dto
|
||||
|
||||
// AuditEventListRequest 是平台审计事件列表的组合筛选参数。
|
||||
type AuditEventListRequest struct {
|
||||
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间(RFC3339,含时区)"`
|
||||
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间(RFC3339,含时区,不包含该时刻)"`
|
||||
Action string `json:"action" query:"action" description:"稳定动作编码"`
|
||||
Category string `json:"category" query:"category" description:"动作类别"`
|
||||
ActorKind string `json:"actor_kind" query:"actor_kind" description:"操作者类型"`
|
||||
ActorID string `json:"actor_id" query:"actor_id" description:"操作者稳定ID"`
|
||||
Source string `json:"source" query:"source" description:"操作入口来源"`
|
||||
Result string `json:"result" query:"result" description:"结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)"`
|
||||
Risk string `json:"risk" query:"risk" description:"风险等级 (low:低, normal:普通, high:高, critical:严重)"`
|
||||
ScopeType string `json:"scope_type" query:"scope_type" description:"业务范围类型"`
|
||||
ScopeID string `json:"scope_id" query:"scope_id" description:"业务范围稳定ID"`
|
||||
ResourceType string `json:"resource_type" query:"resource_type" description:"Resource Registry 注册类型"`
|
||||
ResourceID string `json:"resource_id" query:"resource_id" description:"资源内部稳定ID"`
|
||||
ResourceKey string `json:"resource_key" query:"resource_key" description:"资源业务稳定Key"`
|
||||
RequestID string `json:"request_id" query:"request_id" description:"HTTP请求关联ID"`
|
||||
CorrelationID string `json:"correlation_id" query:"correlation_id" description:"跨步骤业务链路ID"`
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
}
|
||||
|
||||
// AuditEventIDParams 是审计事件详情路径参数。
|
||||
type AuditEventIDParams struct {
|
||||
EventID string `json:"event_id" path:"event_id" required:"true" description:"稳定审计事件ID"`
|
||||
}
|
||||
|
||||
// AuditActorEventsRequest 是操作者行为时间线参数。
|
||||
type AuditActorEventsRequest struct {
|
||||
Kind string `json:"kind" path:"kind" required:"true" description:"操作者类型 (account:人工账号, openapi:开放接口账号, system_task:系统任务, scheduled_job:计划任务, external_system:外部系统)"`
|
||||
ID string `json:"id" path:"id" required:"true" description:"操作者稳定ID"`
|
||||
Action string `json:"action" query:"action" description:"稳定动作编码"`
|
||||
Result string `json:"result" query:"result" description:"事件结果"`
|
||||
Risk string `json:"risk" query:"risk" description:"风险等级"`
|
||||
ResourceType string `json:"resource_type" query:"resource_type" description:"资源类型"`
|
||||
ResourceID string `json:"resource_id" query:"resource_id" description:"资源内部稳定ID"`
|
||||
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间(RFC3339,含时区)"`
|
||||
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间(RFC3339,含时区,不包含该时刻)"`
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
}
|
||||
|
||||
// AuditResourceSearchRequest 是首批注册资源的精确搜索参数。
|
||||
type AuditResourceSearchRequest struct {
|
||||
ResourceType string `json:"resource_type" query:"resource_type" required:"true" description:"资源类型 (iot_card:IoT卡, device:设备, shop:店铺, order:订单, refund:退款单)"`
|
||||
Keyword string `json:"keyword" query:"keyword" required:"true" description:"精确业务标识;卡支持ICCID/VirtualNo,设备支持VirtualNo/IMEI/SN"`
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
}
|
||||
|
||||
// AuditResourceTimelineRequest 是通用资源时间线参数。
|
||||
type AuditResourceTimelineRequest struct {
|
||||
ResourceType string `json:"resource_type" path:"resource_type" required:"true" description:"Resource Registry 注册类型"`
|
||||
ResourceID string `json:"resource_id" path:"resource_id" required:"true" description:"资源内部稳定ID"`
|
||||
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间(RFC3339,含时区)"`
|
||||
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间(RFC3339,含时区,不包含该时刻)"`
|
||||
Action string `json:"action" query:"action" description:"稳定动作编码"`
|
||||
Result string `json:"result" query:"result" description:"事件结果"`
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
}
|
||||
|
||||
// SubjectResourceActivityRequest 是代理和企业安全资源活动的路径及分页参数。
|
||||
type SubjectResourceActivityRequest struct {
|
||||
ResourceType string `json:"resource_type" path:"resource_type" required:"true" description:"资源类型 (iot_card:IoT卡, device:设备, asset_allocation_record:资产分配记录, exchange_order:换货单, shop:店铺, enterprise:企业)"`
|
||||
Identifier string `json:"identifier" path:"identifier" required:"true" description:"业务稳定标识;卡使用ICCID,设备使用VirtualNo,其他资源使用对应业务编号"`
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
}
|
||||
|
||||
// IntegrationFilterRequest 是外部集成调查的公共受控筛选参数。
|
||||
type IntegrationFilterRequest struct {
|
||||
CreatedFrom string `json:"created_from" query:"created_from" required:"true" description:"开始时间(RFC3339,含时区,必填)"`
|
||||
CreatedTo string `json:"created_to" query:"created_to" required:"true" description:"结束时间(RFC3339,含时区,不包含该时刻,必填)"`
|
||||
IntegrationID string `json:"integration_id" query:"integration_id" description:"稳定外部集成记录ID"`
|
||||
Provider string `json:"provider" query:"provider" description:"外部服务提供方稳定编码"`
|
||||
Direction string `json:"direction" query:"direction" description:"交互方向 (inbound:入站, outbound:出站)"`
|
||||
Operation string `json:"operation" query:"operation" description:"外部操作稳定编码"`
|
||||
Result string `json:"result" query:"result" description:"原始结果 (pending:待处理, success:成功, failed:失败, unknown:结果未知, not_found:未找到, invalid_payload:无效载荷, conflict:冲突, ignored:已忽略, merged:已合并, rate_limited:已限频, completed:已提前完成, cancelled:已取消)"`
|
||||
ResultCategory string `json:"result_category" query:"result_category" description:"派生结果类别 (processing:处理中, succeeded:成功, indeterminate:结果不确定, failed:失败, not_sent:未发送)"`
|
||||
ExternalID string `json:"external_id" query:"external_id" description:"外部系统业务或请求标识"`
|
||||
ResourceType string `json:"resource_type" query:"resource_type" description:"本地主要资源类型"`
|
||||
ResourceID string `json:"resource_id" query:"resource_id" description:"本地主要资源稳定ID"`
|
||||
ResourceKey string `json:"resource_key" query:"resource_key" description:"本地主要资源稳定Key"`
|
||||
TriggerSource string `json:"trigger_source" query:"trigger_source" description:"触发来源稳定编码"`
|
||||
TriggerScene string `json:"trigger_scene" query:"trigger_scene" description:"触发业务场景"`
|
||||
TriggerSeries string `json:"trigger_series" query:"trigger_series" description:"显式技术尝试序列ID"`
|
||||
StateChanged *bool `json:"state_changed" query:"state_changed" description:"是否改变本地业务状态"`
|
||||
HTTPStatus *int `json:"http_status" query:"http_status" minimum:"100" maximum:"599" description:"外部HTTP响应状态码"`
|
||||
ProviderCode string `json:"provider_code" query:"provider_code" description:"外部服务稳定结果码"`
|
||||
RequestID string `json:"request_id" query:"request_id" description:"来源HTTP请求ID"`
|
||||
CorrelationID string `json:"correlation_id" query:"correlation_id" description:"跨步骤业务链路ID"`
|
||||
}
|
||||
|
||||
// IntegrationOverviewRequest 是外部集成交互总览参数。
|
||||
type IntegrationOverviewRequest struct {
|
||||
IntegrationFilterRequest
|
||||
Bucket string `json:"bucket" query:"bucket" description:"趋势时间粒度 (hour:小时, day:自然日),默认hour"`
|
||||
}
|
||||
|
||||
// IntegrationListRequest 是外部集成交互列表参数。
|
||||
type IntegrationListRequest struct {
|
||||
IntegrationFilterRequest
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
}
|
||||
|
||||
// IntegrationIDParams 是外部集成详情路径参数。
|
||||
type IntegrationIDParams struct {
|
||||
IntegrationID string `json:"integration_id" path:"integration_id" required:"true" description:"稳定外部集成记录ID,来自列表、通知target_key或调查节点"`
|
||||
}
|
||||
@@ -163,7 +163,6 @@ type ShopBusinessOwnerCandidatePageResult struct {
|
||||
type UpdateShopCreditLimitRequest struct {
|
||||
CreditEnabled *bool `json:"credit_enabled" validate:"required" required:"true" description:"是否启用实际信用额度"`
|
||||
CreditLimit *int64 `json:"credit_limit" validate:"required,min=0" required:"true" minimum:"0" description:"实际信用额度(分);关闭时必须为0"`
|
||||
Version *int `json:"version" validate:"required,min=0" required:"true" minimum:"0" description:"主钱包乐观锁版本"`
|
||||
}
|
||||
|
||||
// UpdateShopCreditLimitParams 调整既有店铺实际信用额度参数。
|
||||
|
||||
18
internal/model/dto/shop_dto_test.go
Normal file
18
internal/model/dto/shop_dto_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUpdateShopCreditLimitRequestDoesNotExposeVersion 验证乐观锁版本不属于前端调额契约。
|
||||
func TestUpdateShopCreditLimitRequestDoesNotExposeVersion(t *testing.T) {
|
||||
typeOfRequest := reflect.TypeOf(UpdateShopCreditLimitRequest{})
|
||||
for i := 0; i < typeOfRequest.NumField(); i++ {
|
||||
jsonName := strings.Split(typeOfRequest.Field(i).Tag.Get("json"), ",")[0]
|
||||
if jsonName == "version" {
|
||||
t.Fatal("调额请求不应暴露由服务端管理的乐观锁版本")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ type IntegrationLog struct {
|
||||
Metadata datatypes.JSON `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"`
|
||||
RecoveryStrategy *string `gorm:"column:recovery_strategy;type:varchar(255)" json:"recovery_strategy,omitempty"`
|
||||
RequestID *string `gorm:"column:request_id;type:varchar(64)" json:"request_id,omitempty"`
|
||||
CorrelationID *string `gorm:"column:correlation_id;type:varchar(64)" json:"correlation_id,omitempty"`
|
||||
CorrelationID *string `gorm:"column:correlation_id;type:varchar(100)" json:"correlation_id,omitempty"`
|
||||
AuditEventID *uint `gorm:"column:audit_event_id" json:"audit_event_id,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
|
||||
|
||||
@@ -19,6 +19,7 @@ type OutboxEvent struct {
|
||||
BusinessKey string `gorm:"column:business_key;type:varchar(150);not null;default:''" json:"business_key,omitempty"`
|
||||
RequestID string `gorm:"column:request_id;type:varchar(100);not null;default:'';index" json:"request_id,omitempty"`
|
||||
CorrelationID string `gorm:"column:correlation_id;type:varchar(100);not null;default:'';index" json:"correlation_id,omitempty"`
|
||||
ParentEventID string `gorm:"column:parent_event_id;type:varchar(64);not null;default:'';index" json:"parent_event_id,omitempty"`
|
||||
Payload datatypes.JSON `gorm:"column:payload;type:jsonb;not null" json:"payload"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;index:idx_outbox_event_type_status,priority:2" json:"status"`
|
||||
RetryCount int `gorm:"column:retry_count;type:int;not null;default:0" json:"retry_count"`
|
||||
|
||||
49
internal/query/audit/actors.go
Normal file
49
internal/query/audit/actors.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ActorEventFilter 定义操作者行为视角的受控筛选。
|
||||
type ActorEventFilter struct {
|
||||
Kind string
|
||||
ID string
|
||||
Action string
|
||||
Result string
|
||||
Risk string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// ListActorEvents 查询指定人工账号、OpenAPI、系统任务或外部系统的历史行为。
|
||||
func (q *Query) ListActorEvents(ctx context.Context, filter ActorEventFilter) (*EventPage, error) {
|
||||
if filter.ID == "" || !validActorKind(filter.Kind) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
return q.List(ctx, EventFilter{
|
||||
ActorKind: filter.Kind, ActorID: filter.ID,
|
||||
Action: filter.Action, Result: filter.Result, Risk: filter.Risk,
|
||||
ResourceType: filter.ResourceType, ResourceID: filter.ResourceID,
|
||||
CreatedFrom: filter.CreatedFrom, CreatedTo: filter.CreatedTo,
|
||||
Page: filter.Page, PageSize: filter.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func validActorKind(kind string) bool {
|
||||
switch kind {
|
||||
case constants.AuditActorAccount, constants.AuditActorPersonalCustomer,
|
||||
constants.AuditActorOpenAPI, constants.AuditActorSystemTask,
|
||||
constants.AuditActorScheduledJob, constants.AuditActorExternalSystem:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
396
internal/query/audit/events.go
Normal file
396
internal/query/audit/events.go
Normal file
@@ -0,0 +1,396 @@
|
||||
// Package audit 提供统一审计事件的只读调查查询。
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// EventFilter 定义平台全局事件列表的受控组合筛选。
|
||||
type EventFilter struct {
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
Action string
|
||||
Category string
|
||||
ActorKind string
|
||||
ActorID string
|
||||
Source string
|
||||
Result string
|
||||
Risk string
|
||||
ScopeType string
|
||||
ScopeID string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
ResourceKey string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// EventPage 是平台全局事件稳定分页结果。
|
||||
type EventPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []EventView `json:"items"`
|
||||
}
|
||||
|
||||
// EventView 是不暴露 GORM Model 的审计事件投影。
|
||||
type EventView struct {
|
||||
EventID string `json:"event_id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Category string `json:"category"`
|
||||
ActionCode string `json:"action_code"`
|
||||
ActionName string `json:"action_name"`
|
||||
Summary string `json:"summary"`
|
||||
ActorKind string `json:"actor_kind"`
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorName string `json:"actor_name"`
|
||||
ActorShopID *uint `json:"actor_shop_id,omitempty"`
|
||||
ActorShopName string `json:"actor_shop_name"`
|
||||
ActorEnterpriseID *uint `json:"actor_enterprise_id,omitempty"`
|
||||
ActorEnterpriseName string `json:"actor_enterprise_name"`
|
||||
Source string `json:"source"`
|
||||
RequestPath string `json:"request_path"`
|
||||
RequestMethod string `json:"request_method"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
ScopeType string `json:"scope_type"`
|
||||
ScopeID string `json:"scope_id"`
|
||||
ScopeName string `json:"scope_name"`
|
||||
Result string `json:"result"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
ErrorSummary string `json:"error_summary"`
|
||||
RequestID string `json:"request_id"`
|
||||
CorrelationID string `json:"correlation_id"`
|
||||
ParentEventID string `json:"parent_event_id"`
|
||||
BatchTotal int `json:"batch_total"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailCount int `json:"fail_count"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Resources []ResourceView `json:"resources"`
|
||||
InvestigationRefs InvestigationRefs `json:"investigation_refs"`
|
||||
}
|
||||
|
||||
// InvestigationRefs 是平台调查视角间唯一允许使用的稳定跳转引用。
|
||||
type InvestigationRefs struct {
|
||||
EventID *string `json:"event_id"`
|
||||
ActorRef *ActorRef `json:"actor_ref"`
|
||||
ResourceRefs []InvestigationResourceRef `json:"resource_refs"`
|
||||
RequestID *string `json:"request_id"`
|
||||
CorrelationID *string `json:"correlation_id"`
|
||||
IntegrationRefs []IntegrationRef `json:"integration_refs"`
|
||||
}
|
||||
|
||||
// ActorRef 是操作者时间线的稳定引用。
|
||||
type ActorRef struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// InvestigationResourceRef 是通用资源时间线的稳定引用。
|
||||
type InvestigationResourceRef struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID *string `json:"resource_id"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// IntegrationRef 是 Integration 详情的稳定引用。
|
||||
type IntegrationRef struct {
|
||||
IntegrationID string `json:"integration_id"`
|
||||
}
|
||||
|
||||
// ResourceView 是事件发生时独立资源身份与变化的只读投影。
|
||||
type ResourceView struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID *string `json:"resource_id,omitempty"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Relation string `json:"relation"`
|
||||
Role string `json:"role"`
|
||||
IdentitySnapshot map[string]any `json:"identity_snapshot"`
|
||||
BeforeData map[string]any `json:"before_data"`
|
||||
AfterData map[string]any `json:"after_data"`
|
||||
SubjectVisibility string `json:"subject_visibility"`
|
||||
SubjectSummary string `json:"subject_summary"`
|
||||
SubjectData map[string]any `json:"subject_data"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Query 提供平台统一审计事件列表与详情读取。
|
||||
type Query struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// New 创建统一审计事件 Query。
|
||||
func New(db *gorm.DB) *Query {
|
||||
return &Query{db: db}
|
||||
}
|
||||
|
||||
// List 查询平台范围的全局审计事件。
|
||||
func (q *Query) List(ctx context.Context, filter EventFilter) (*EventPage, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validEventFilter(filter) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||||
query := q.applyFilters(q.db.WithContext(ctx).Model(&model.AuditEvent{}), filter)
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计审计事件失败")
|
||||
}
|
||||
rows := make([]model.AuditEvent, 0, filter.PageSize)
|
||||
if err := query.Order("occurred_at DESC, id DESC").
|
||||
Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审计事件失败")
|
||||
}
|
||||
items, err := q.project(ctx, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EventPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
|
||||
}
|
||||
|
||||
func validEventFilter(filter EventFilter) bool {
|
||||
return validOptionalValue(filter.Result, constants.AuditResultSuccess, constants.AuditResultFailed,
|
||||
constants.AuditResultDenied, constants.AuditResultPartial, constants.AuditResultUnknown) &&
|
||||
validOptionalValue(filter.Risk, constants.AuditRiskLow, constants.AuditRiskNormal,
|
||||
constants.AuditRiskHigh, constants.AuditRiskCritical) &&
|
||||
validOptionalValue(filter.Source, constants.AuditSourceAdminAPI, constants.AuditSourcePersonalAPI,
|
||||
constants.AuditSourceOpenAPI, constants.AuditSourceWorker, constants.AuditSourceScheduler, constants.AuditSourceCallback) &&
|
||||
(filter.ActorKind == "" || validActorKind(filter.ActorKind)) &&
|
||||
filter.Page >= 0 && filter.PageSize >= 0 && filter.PageSize <= constants.MaxPageSize
|
||||
}
|
||||
|
||||
func validOptionalValue(value string, allowed ...string) bool {
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if value == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Get 查询平台范围的单个稳定审计事件详情。
|
||||
func (q *Query) Get(ctx context.Context, eventID string) (*EventView, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if eventID == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
var row model.AuditEvent
|
||||
if err := q.db.WithContext(ctx).Where("event_id = ?", eventID).First(&row).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "审计事件不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审计事件详情失败")
|
||||
}
|
||||
items, err := q.project(ctx, []model.AuditEvent{row})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &items[0], nil
|
||||
}
|
||||
|
||||
func (q *Query) authorize(ctx context.Context) error {
|
||||
if q == nil || q.db == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "审计查询能力未配置")
|
||||
}
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *Query) applyFilters(query *gorm.DB, filter EventFilter) *gorm.DB {
|
||||
if filter.CreatedFrom != nil {
|
||||
query = query.Where("occurred_at >= ?", filter.CreatedFrom.UTC())
|
||||
}
|
||||
if filter.CreatedTo != nil {
|
||||
query = query.Where("occurred_at < ?", filter.CreatedTo.UTC())
|
||||
}
|
||||
for column, value := range map[string]string{
|
||||
"action_code": filter.Action, "category": filter.Category,
|
||||
"actor_kind": filter.ActorKind, "actor_id": filter.ActorID,
|
||||
"source": filter.Source, "result": filter.Result, "risk_level": filter.Risk,
|
||||
"scope_type": filter.ScopeType, "scope_id": filter.ScopeID,
|
||||
"request_id": filter.RequestID, "correlation_id": filter.CorrelationID,
|
||||
} {
|
||||
if value != "" {
|
||||
query = query.Where(column+" = ?", value)
|
||||
}
|
||||
}
|
||||
if filter.ResourceType != "" || filter.ResourceID != "" || filter.ResourceKey != "" {
|
||||
resource := q.db.Table("tb_audit_event_resource AS aer").Select("1").
|
||||
Where("aer.audit_event_id = tb_audit_event.id")
|
||||
if filter.ResourceType != "" {
|
||||
resource = resource.Where("aer.resource_type = ?", filter.ResourceType)
|
||||
}
|
||||
if filter.ResourceID != "" {
|
||||
resource = resource.Where("aer.resource_id = ?", filter.ResourceID)
|
||||
}
|
||||
if filter.ResourceKey != "" {
|
||||
resource = resource.Where("aer.resource_key = ?", filter.ResourceKey)
|
||||
}
|
||||
query = query.Where("EXISTS (?)", resource)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func (q *Query) project(ctx context.Context, rows []model.AuditEvent) ([]EventView, error) {
|
||||
items := make([]EventView, len(rows))
|
||||
if len(rows) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
ids := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
var resources []model.AuditEventResource
|
||||
if err := q.db.WithContext(ctx).Where("audit_event_id IN ?", ids).
|
||||
Order("audit_event_id ASC, sort_order ASC, id ASC").Find(&resources).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询审计事件资源失败")
|
||||
}
|
||||
resourcesByEvent := make(map[uint][]ResourceView, len(rows))
|
||||
for _, resource := range resources {
|
||||
view, err := projectResource(resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resourcesByEvent[resource.AuditEventID] = append(resourcesByEvent[resource.AuditEventID], view)
|
||||
}
|
||||
for index, row := range rows {
|
||||
metadata, err := decodeObject(row.Metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items[index] = EventView{
|
||||
EventID: row.EventID, OccurredAt: row.OccurredAt, Category: row.Category,
|
||||
ActionCode: row.ActionCode, ActionName: row.ActionName, Summary: row.Summary,
|
||||
ActorKind: row.ActorKind, ActorID: row.ActorID, ActorName: row.ActorName,
|
||||
ActorShopID: row.ActorShopID, ActorShopName: row.ActorShopName,
|
||||
ActorEnterpriseID: row.ActorEnterpriseID, ActorEnterpriseName: row.ActorEnterpriseName,
|
||||
Source: row.Source, RequestPath: row.RequestPath, RequestMethod: row.RequestMethod,
|
||||
IPAddress: row.IPAddress, UserAgent: row.UserAgent,
|
||||
ScopeType: row.ScopeType, ScopeID: row.ScopeID, ScopeName: row.ScopeName,
|
||||
Result: row.Result, RiskLevel: row.RiskLevel, ErrorCode: row.ErrorCode, ErrorSummary: row.ErrorSummary,
|
||||
RequestID: row.RequestID, CorrelationID: row.CorrelationID, ParentEventID: row.ParentEventID,
|
||||
BatchTotal: row.BatchTotal, SuccessCount: row.SuccessCount, FailCount: row.FailCount,
|
||||
Metadata: metadata, ContentHash: row.ContentHash, CreatedAt: row.CreatedAt,
|
||||
Resources: resourcesByEvent[row.ID],
|
||||
}
|
||||
if items[index].Resources == nil {
|
||||
items[index].Resources = []ResourceView{}
|
||||
}
|
||||
items[index].InvestigationRefs = investigationRefs(row, items[index].Resources)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func investigationRefs(event model.AuditEvent, resources []ResourceView) InvestigationRefs {
|
||||
refs := InvestigationRefs{
|
||||
EventID: stringPointer(event.EventID), ActorRef: investigationActorRef(event.ActorKind, event.ActorID),
|
||||
ResourceRefs: make([]InvestigationResourceRef, 0, len(resources)),
|
||||
RequestID: stringPointer(event.RequestID), CorrelationID: stringPointer(event.CorrelationID),
|
||||
IntegrationRefs: []IntegrationRef{},
|
||||
}
|
||||
for _, resource := range resources {
|
||||
refs.ResourceRefs = append(refs.ResourceRefs, InvestigationResourceRef{
|
||||
ResourceType: resource.ResourceType, ResourceID: resource.ResourceID,
|
||||
ResourceKey: resource.ResourceKey, DisplayName: resource.DisplayName,
|
||||
})
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func investigationActorRef(kind, id string) *ActorRef {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
switch kind {
|
||||
case constants.AuditActorAccount, constants.AuditActorOpenAPI, constants.AuditActorSystemTask,
|
||||
constants.AuditActorScheduledJob, constants.AuditActorExternalSystem:
|
||||
return &ActorRef{Kind: kind, ID: id}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func stringPointer(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func projectResource(row model.AuditEventResource) (ResourceView, error) {
|
||||
identity, err := decodeObject(row.IdentitySnapshot)
|
||||
if err != nil {
|
||||
return ResourceView{}, err
|
||||
}
|
||||
before, err := decodeObject(row.BeforeData)
|
||||
if err != nil {
|
||||
return ResourceView{}, err
|
||||
}
|
||||
after, err := decodeObject(row.AfterData)
|
||||
if err != nil {
|
||||
return ResourceView{}, err
|
||||
}
|
||||
subject, err := decodeObject(row.SubjectData)
|
||||
if err != nil {
|
||||
return ResourceView{}, err
|
||||
}
|
||||
return ResourceView{
|
||||
ResourceType: row.ResourceType, ResourceID: row.ResourceID, ResourceKey: row.ResourceKey,
|
||||
DisplayName: row.DisplayName, Relation: row.Relation, Role: row.Role,
|
||||
IdentitySnapshot: identity, BeforeData: before, AfterData: after,
|
||||
SubjectVisibility: row.SubjectVisibility, SubjectSummary: row.SubjectSummary, SubjectData: subject,
|
||||
SortOrder: row.SortOrder, CreatedAt: row.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeObject(value datatypes.JSON) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
if len(value) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if err := sonic.Unmarshal(value, &result); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "解析审计结构化字段失败")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizePage(page, pageSize int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
if pageSize > constants.MaxPageSize {
|
||||
pageSize = constants.MaxPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
247
internal/query/audit/resources.go
Normal file
247
internal/query/audit/resources.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ResourceSearchFilter 定义注册资源的精确标识搜索。
|
||||
type ResourceSearchFilter struct {
|
||||
ResourceType string
|
||||
Keyword string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// ResourceSearchPage 是资源候选稳定分页结果。
|
||||
type ResourceSearchPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []ResourceCandidate `json:"items"`
|
||||
}
|
||||
|
||||
// ResourceCandidate 是当前业务表或历史事件快照解析出的稳定资源候选。
|
||||
type ResourceCandidate struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IdentitySnapshot map[string]any `json:"identity_snapshot"`
|
||||
Historical bool `json:"historical"`
|
||||
}
|
||||
|
||||
// ResourceTimelineFilter 定义通用资源时间线筛选。
|
||||
type ResourceTimelineFilter struct {
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
Action string
|
||||
Result string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// SearchResources 按 Resource Registry 声明的稳定标识精确搜索资源。
|
||||
func (q *Query) SearchResources(ctx context.Context, filter ResourceSearchFilter) (*ResourceSearchPage, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter.Keyword == "" || !searchableResourceType(filter.ResourceType) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||||
items, total, err := q.searchCurrent(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total == 0 {
|
||||
items, total, err = q.searchHistorical(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &ResourceSearchPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
|
||||
}
|
||||
|
||||
// ResourceTimeline 查询注册资源作为任意关系参与的统一事件时间线。
|
||||
func (q *Query) ResourceTimeline(ctx context.Context, filter ResourceTimelineFilter) (*EventPage, error) {
|
||||
if filter.ResourceID == "" || !timelineResourceType(filter.ResourceType) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
return q.List(ctx, EventFilter{
|
||||
ResourceType: filter.ResourceType, ResourceID: filter.ResourceID,
|
||||
CreatedFrom: filter.CreatedFrom, CreatedTo: filter.CreatedTo,
|
||||
Action: filter.Action, Result: filter.Result,
|
||||
Page: filter.Page, PageSize: filter.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func timelineResourceType(resourceType string) bool {
|
||||
switch resourceType {
|
||||
case constants.AuditResourceAccount, constants.AuditResourceShop, constants.AuditResourceEnterprise,
|
||||
constants.AuditResourceIotCard, constants.AuditResourceDevice, constants.AuditResourceDeviceSIMBinding,
|
||||
constants.AuditResourceAssetAllocationRecord, constants.AuditResourceExchangeOrder, constants.AuditResourceOrder,
|
||||
constants.AuditResourceRefund, constants.AuditResourceAgentRecharge, constants.AuditResourceAssetWallet,
|
||||
constants.AuditResourceApprovalInstance:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Query) searchCurrent(ctx context.Context, filter ResourceSearchFilter) ([]ResourceCandidate, int64, error) {
|
||||
switch filter.ResourceType {
|
||||
case constants.AuditResourceIotCard:
|
||||
var rows []model.IotCard
|
||||
query := q.db.WithContext(ctx).Where("iccid = ? OR virtual_no = ? OR iccid_19 = ? OR iccid_20 = ?", filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword)
|
||||
return searchModels(query, filter, &rows, func(row model.IotCard) ResourceCandidate {
|
||||
return candidate(filter.ResourceType, row.ID, row.ICCID, row.ICCID, map[string]any{
|
||||
"id": row.ID, "iccid": row.ICCID, "virtual_no": row.VirtualNo, "msisdn": row.MSISDN,
|
||||
"carrier_type": row.CarrierType, "shop_id": row.ShopID, "series_id": row.SeriesID, "generation": row.Generation,
|
||||
})
|
||||
})
|
||||
case constants.AuditResourceDevice:
|
||||
var rows []model.Device
|
||||
query := q.db.WithContext(ctx).Where("virtual_no = ? OR imei = ? OR sn = ?", filter.Keyword, filter.Keyword, filter.Keyword)
|
||||
return searchModels(query, filter, &rows, func(row model.Device) ResourceCandidate {
|
||||
return candidate(filter.ResourceType, row.ID, deviceCandidateKey(row), deviceCandidateKey(row), map[string]any{
|
||||
"id": row.ID, "virtual_no": row.VirtualNo, "imei": row.IMEI, "sn": row.SN,
|
||||
"device_name": row.DeviceName, "device_model": row.DeviceModel, "shop_id": row.ShopID,
|
||||
"series_id": row.SeriesID, "generation": row.Generation,
|
||||
})
|
||||
})
|
||||
case constants.AuditResourceShop:
|
||||
var rows []model.Shop
|
||||
return searchModels(q.db.WithContext(ctx).Where("shop_code = ?", filter.Keyword), filter, &rows, func(row model.Shop) ResourceCandidate {
|
||||
return candidate(filter.ResourceType, row.ID, row.ShopCode, row.ShopName, map[string]any{
|
||||
"id": row.ID, "shop_code": row.ShopCode, "shop_name": row.ShopName, "parent_id": row.ParentID, "level": row.Level,
|
||||
})
|
||||
})
|
||||
case constants.AuditResourceOrder:
|
||||
var rows []model.Order
|
||||
return searchModels(q.db.WithContext(ctx).Where("order_no = ?", filter.Keyword), filter, &rows, func(row model.Order) ResourceCandidate {
|
||||
return candidate(filter.ResourceType, row.ID, row.OrderNo, row.OrderNo, map[string]any{
|
||||
"id": row.ID, "order_no": row.OrderNo, "buyer_type": row.BuyerType, "buyer_id": row.BuyerID,
|
||||
"asset_identifier": row.AssetIdentifier, "total_amount": row.TotalAmount,
|
||||
"payment_method": row.PaymentMethod, "payment_status": row.PaymentStatus,
|
||||
})
|
||||
})
|
||||
case constants.AuditResourceRefund:
|
||||
var rows []model.RefundRequest
|
||||
return searchModels(q.db.WithContext(ctx).Where("refund_no = ?", filter.Keyword), filter, &rows, func(row model.RefundRequest) ResourceCandidate {
|
||||
return candidate(filter.ResourceType, row.ID, row.RefundNo, row.RefundNo, map[string]any{
|
||||
"id": row.ID, "refund_no": row.RefundNo, "order_id": row.OrderID, "order_no": row.OrderNo,
|
||||
"asset_identifier": row.AssetIdentifier, "shop_id": row.ShopID,
|
||||
"requested_refund_amount": row.RequestedRefundAmount, "status": row.Status,
|
||||
})
|
||||
})
|
||||
default:
|
||||
return nil, 0, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
}
|
||||
|
||||
func searchModels[T any](query *gorm.DB, filter ResourceSearchFilter, rows *[]T, project func(T) ResourceCandidate) ([]ResourceCandidate, int64, error) {
|
||||
var total int64
|
||||
if err := query.Model(new(T)).Count(&total).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计资源候选失败")
|
||||
}
|
||||
if err := query.Order("id ASC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(rows).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资源候选失败")
|
||||
}
|
||||
items := make([]ResourceCandidate, 0, len(*rows))
|
||||
for _, row := range *rows {
|
||||
items = append(items, project(row))
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (q *Query) searchHistorical(ctx context.Context, filter ResourceSearchFilter) ([]ResourceCandidate, int64, error) {
|
||||
base := q.historicalIdentifierQuery(ctx, filter)
|
||||
var total int64
|
||||
if err := base.Distinct("resource_id").Count(&total).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计历史资源候选失败")
|
||||
}
|
||||
latest := q.historicalIdentifierQuery(ctx, filter).
|
||||
Select("DISTINCT ON (resource_id) resource_id, resource_key, display_name, identity_snapshot, created_at, id").
|
||||
Order("resource_id ASC, created_at DESC, id DESC")
|
||||
var rows []historicalResourceRow
|
||||
if err := q.db.WithContext(ctx).Table("(?) AS historical", latest).
|
||||
Order("resource_id ASC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询历史资源候选失败")
|
||||
}
|
||||
items := make([]ResourceCandidate, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
identity, err := decodeObject(row.IdentitySnapshot)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, ResourceCandidate{
|
||||
ResourceType: filter.ResourceType, ResourceID: row.ResourceID,
|
||||
ResourceKey: row.ResourceKey, DisplayName: row.DisplayName,
|
||||
IdentitySnapshot: identity, Historical: true,
|
||||
})
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (q *Query) historicalIdentifierQuery(ctx context.Context, filter ResourceSearchFilter) *gorm.DB {
|
||||
query := q.db.WithContext(ctx).Model(&model.AuditEventResource{}).
|
||||
Where("resource_type = ? AND resource_id IS NOT NULL", filter.ResourceType)
|
||||
switch filter.ResourceType {
|
||||
case constants.AuditResourceIotCard:
|
||||
return query.Where("resource_key = ? OR identity_snapshot ->> 'iccid' = ? OR identity_snapshot ->> 'iccid_19' = ? OR identity_snapshot ->> 'iccid_20' = ? OR identity_snapshot ->> 'virtual_no' = ?", filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword)
|
||||
case constants.AuditResourceDevice:
|
||||
return query.Where("resource_key = ? OR identity_snapshot ->> 'virtual_no' = ? OR identity_snapshot ->> 'imei' = ? OR identity_snapshot ->> 'sn' = ?", filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword)
|
||||
case constants.AuditResourceShop:
|
||||
return query.Where("resource_key = ? OR identity_snapshot ->> 'shop_code' = ?", filter.Keyword, filter.Keyword)
|
||||
case constants.AuditResourceOrder:
|
||||
return query.Where("resource_key = ? OR identity_snapshot ->> 'order_no' = ?", filter.Keyword, filter.Keyword)
|
||||
case constants.AuditResourceRefund:
|
||||
return query.Where("resource_key = ? OR identity_snapshot ->> 'refund_no' = ?", filter.Keyword, filter.Keyword)
|
||||
default:
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
}
|
||||
|
||||
type historicalResourceRow struct {
|
||||
ResourceID string
|
||||
ResourceKey string
|
||||
DisplayName string
|
||||
IdentitySnapshot datatypes.JSON
|
||||
}
|
||||
|
||||
func candidate(resourceType string, id uint, key, name string, identity map[string]any) ResourceCandidate {
|
||||
return ResourceCandidate{
|
||||
ResourceType: resourceType, ResourceID: strconv.FormatUint(uint64(id), 10),
|
||||
ResourceKey: key, DisplayName: name, IdentitySnapshot: identity,
|
||||
}
|
||||
}
|
||||
|
||||
func searchableResourceType(resourceType string) bool {
|
||||
switch resourceType {
|
||||
case constants.AuditResourceIotCard, constants.AuditResourceDevice, constants.AuditResourceShop,
|
||||
constants.AuditResourceOrder, constants.AuditResourceRefund:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func deviceCandidateKey(row model.Device) string {
|
||||
for _, value := range []string{row.VirtualNo, row.IMEI, row.SN} {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return strconv.FormatUint(uint64(row.ID), 10)
|
||||
}
|
||||
395
internal/query/audit/subject_activities.go
Normal file
395
internal/query/audit/subject_activities.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// SubjectActivityFilter 定义代理资源活动的稳定标识和分页参数。
|
||||
type SubjectActivityFilter struct {
|
||||
ResourceType string
|
||||
Identifier string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// SubjectActivityPage 是不包含平台调查字段的代理资源活动分页结果。
|
||||
type SubjectActivityPage struct {
|
||||
Resource SubjectResourceSummary `json:"resource"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []SubjectActivity `json:"items"`
|
||||
}
|
||||
|
||||
// SubjectActivity 是写入时已生成的主体安全活动投影。
|
||||
type SubjectActivity struct {
|
||||
ActionCode string `json:"action_code"`
|
||||
ActionName string `json:"action_name"`
|
||||
SubjectSummary string `json:"subject_summary"`
|
||||
SubjectData map[string]any `json:"subject_data"`
|
||||
Result string `json:"result"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
RelatedResources []SubjectResourceSummary `json:"related_resources"`
|
||||
}
|
||||
|
||||
// SubjectResourceSummary 是主体活动允许公开的资源摘要。
|
||||
type SubjectResourceSummary struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
type subjectTarget struct {
|
||||
summary SubjectResourceSummary
|
||||
id string
|
||||
}
|
||||
|
||||
type subjectActivityRow struct {
|
||||
ID uint
|
||||
ActionCode string
|
||||
ActionName string
|
||||
Result string
|
||||
OccurredAt time.Time
|
||||
SubjectSummary string
|
||||
SubjectData datatypes.JSON
|
||||
TargetResourceID uint
|
||||
}
|
||||
|
||||
type subjectResourceAuthorizer func(context.Context, []model.AuditEventResource) (map[string]bool, error)
|
||||
|
||||
// AgentResourceActivities 查询代理自身及下级店铺范围内的安全资源活动。
|
||||
func (q *Query) AgentResourceActivities(ctx context.Context, filter SubjectActivityFilter) (*SubjectActivityPage, error) {
|
||||
shopIDs, err := q.agentShopScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter.Identifier == "" || !agentActivityResourceType(filter.ResourceType) || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||||
target, err := q.resolveAgentTarget(ctx, filter.ResourceType, filter.Identifier, shopIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.subjectActivitiesForTarget(ctx, filter, target, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) {
|
||||
return q.agentAllowedResourceIDs(ctx, resources, shopIDs)
|
||||
})
|
||||
}
|
||||
|
||||
// EnterpriseResourceActivities 查询企业当前有效授权卡或设备的安全资源活动。
|
||||
func (q *Query) EnterpriseResourceActivities(ctx context.Context, filter SubjectActivityFilter) (*SubjectActivityPage, error) {
|
||||
if q == nil || q.db == nil || middleware.GetUserTypeFromContext(ctx) != constants.UserTypeEnterprise {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
enterpriseID := middleware.GetEnterpriseIDFromContext(ctx)
|
||||
if enterpriseID == 0 {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
if filter.Identifier == "" || !enterpriseActivityResourceType(filter.ResourceType) || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||||
target, err := q.resolveEnterpriseTarget(ctx, filter.ResourceType, filter.Identifier, enterpriseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.subjectActivitiesForTarget(ctx, filter, target, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) {
|
||||
return q.enterpriseAllowedResourceIDs(ctx, resources, enterpriseID)
|
||||
})
|
||||
}
|
||||
|
||||
func (q *Query) subjectActivitiesForTarget(ctx context.Context, filter SubjectActivityFilter, target subjectTarget, authorize subjectResourceAuthorizer) (*SubjectActivityPage, error) {
|
||||
|
||||
resourceMatch := q.db.Table("tb_audit_event_resource AS target").Select("1").
|
||||
Where("target.audit_event_id = tb_audit_event.id AND target.resource_type = ? AND target.resource_id = ?", filter.ResourceType, target.id).
|
||||
Where("target.subject_visibility IN ?", []string{constants.AuditSubjectResult, constants.AuditSubjectDetail})
|
||||
base := q.db.WithContext(ctx).Model(&model.AuditEvent{}).Where("EXISTS (?)", resourceMatch)
|
||||
var total int64
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计代理资源活动失败")
|
||||
}
|
||||
|
||||
rows := make([]subjectActivityRow, 0, filter.PageSize)
|
||||
if err := base.Select("tb_audit_event.id, action_code, action_name, result, occurred_at, target.subject_summary, target.subject_data, target.id AS target_resource_id").
|
||||
Joins("JOIN tb_audit_event_resource AS target ON target.audit_event_id = tb_audit_event.id AND target.resource_type = ? AND target.resource_id = ?", filter.ResourceType, target.id).
|
||||
Where("target.subject_visibility IN ?", []string{constants.AuditSubjectResult, constants.AuditSubjectDetail}).
|
||||
Order("occurred_at DESC, tb_audit_event.id DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理资源活动失败")
|
||||
}
|
||||
items, err := q.projectSubjectActivities(ctx, rows, authorize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SubjectActivityPage{Resource: target.summary, Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
|
||||
}
|
||||
|
||||
func (q *Query) resolveEnterpriseTarget(ctx context.Context, resourceType, identifier string, enterpriseID uint) (subjectTarget, error) {
|
||||
var target subjectTarget
|
||||
switch resourceType {
|
||||
case constants.AuditResourceIotCard:
|
||||
var row model.IotCard
|
||||
err := q.db.WithContext(ctx).Table("tb_iot_card AS card").
|
||||
Joins("JOIN tb_enterprise_card_authorization AS auth ON auth.card_id = card.id AND auth.deleted_at IS NULL AND auth.revoked_at IS NULL").
|
||||
Where("card.iccid = ? AND auth.enterprise_id = ? AND card.deleted_at IS NULL", identifier, enterpriseID).First(&row).Error
|
||||
if err != nil {
|
||||
return target, q.subjectTargetError(err)
|
||||
}
|
||||
target = newSubjectTarget(resourceType, row.ID, row.ICCID, row.ICCID)
|
||||
case constants.AuditResourceDevice:
|
||||
var row model.Device
|
||||
err := q.db.WithContext(ctx).Table("tb_device AS device").
|
||||
Joins("JOIN tb_enterprise_device_authorization AS auth ON auth.device_id = device.id AND auth.deleted_at IS NULL AND auth.revoked_at IS NULL").
|
||||
Where("device.virtual_no = ? AND auth.enterprise_id = ? AND device.deleted_at IS NULL", identifier, enterpriseID).First(&row).Error
|
||||
if err != nil {
|
||||
return target, q.subjectTargetError(err)
|
||||
}
|
||||
target = newSubjectTarget(resourceType, row.ID, row.VirtualNo, row.VirtualNo)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (q *Query) agentShopScope(ctx context.Context) ([]uint, error) {
|
||||
if q == nil || q.db == nil || middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
shopIDs := middleware.GetSubordinateShopIDs(ctx)
|
||||
if len(shopIDs) == 0 {
|
||||
if shopID := middleware.GetShopIDFromContext(ctx); shopID > 0 {
|
||||
return []uint{shopID}, nil
|
||||
}
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return shopIDs, nil
|
||||
}
|
||||
|
||||
func (q *Query) resolveAgentTarget(ctx context.Context, resourceType, identifier string, shopIDs []uint) (subjectTarget, error) {
|
||||
var target subjectTarget
|
||||
query := q.db.WithContext(ctx)
|
||||
switch resourceType {
|
||||
case constants.AuditResourceIotCard:
|
||||
var row model.IotCard
|
||||
if err := query.Where("iccid = ? AND shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
|
||||
target = newSubjectTarget(resourceType, row.ID, row.ICCID, row.ICCID)
|
||||
} else {
|
||||
return target, q.subjectTargetError(err)
|
||||
}
|
||||
case constants.AuditResourceDevice:
|
||||
var row model.Device
|
||||
if err := query.Where("virtual_no = ? AND shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
|
||||
target = newSubjectTarget(resourceType, row.ID, row.VirtualNo, row.VirtualNo)
|
||||
} else {
|
||||
return target, q.subjectTargetError(err)
|
||||
}
|
||||
case constants.AuditResourceShop:
|
||||
var row model.Shop
|
||||
if err := query.Where("shop_code = ? AND id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
|
||||
target = newSubjectTarget(resourceType, row.ID, row.ShopCode, row.ShopName)
|
||||
} else {
|
||||
return target, q.subjectTargetError(err)
|
||||
}
|
||||
case constants.AuditResourceEnterprise:
|
||||
var row model.Enterprise
|
||||
if err := query.Where("enterprise_code = ? AND owner_shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
|
||||
target = newSubjectTarget(resourceType, row.ID, row.EnterpriseCode, row.EnterpriseName)
|
||||
} else {
|
||||
return target, q.subjectTargetError(err)
|
||||
}
|
||||
case constants.AuditResourceExchangeOrder:
|
||||
var row model.ExchangeOrder
|
||||
if err := query.Where("exchange_no = ? AND shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
|
||||
target = newSubjectTarget(resourceType, row.ID, row.ExchangeNo, row.ExchangeNo)
|
||||
} else {
|
||||
return target, q.subjectTargetError(err)
|
||||
}
|
||||
case constants.AuditResourceAssetAllocationRecord:
|
||||
var row model.AssetAllocationRecord
|
||||
err := agentAllocationScope(query.Where("allocation_no = ?", identifier), shopIDs).Order("id DESC").First(&row).Error
|
||||
if err != nil {
|
||||
return target, q.subjectTargetError(err)
|
||||
}
|
||||
target = newSubjectTarget(resourceType, row.ID, row.AllocationNo, row.AllocationNo)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func agentAllocationScope(query *gorm.DB, shopIDs []uint) *gorm.DB {
|
||||
return query.Where(`
|
||||
(from_owner_type = 'shop' AND from_owner_id IN ?) OR
|
||||
(to_owner_type = 'shop' AND to_owner_id IN ?) OR
|
||||
(asset_type = 'iot_card' AND EXISTS (SELECT 1 FROM tb_iot_card c WHERE c.id = asset_id AND c.deleted_at IS NULL AND c.shop_id IN ?)) OR
|
||||
(asset_type = 'device' AND EXISTS (SELECT 1 FROM tb_device d WHERE d.id = asset_id AND d.deleted_at IS NULL AND d.shop_id IN ?))`,
|
||||
shopIDs, shopIDs, shopIDs, shopIDs)
|
||||
}
|
||||
|
||||
func (q *Query) subjectTargetError(err error) error {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验代理资源范围失败")
|
||||
}
|
||||
|
||||
func newSubjectTarget(resourceType string, id uint, key, name string) subjectTarget {
|
||||
resourceID := strconv.FormatUint(uint64(id), 10)
|
||||
return subjectTarget{summary: SubjectResourceSummary{ResourceType: resourceType, ResourceID: resourceID, ResourceKey: key, DisplayName: name}, id: resourceID}
|
||||
}
|
||||
|
||||
func (q *Query) projectSubjectActivities(ctx context.Context, rows []subjectActivityRow, authorize subjectResourceAuthorizer) ([]SubjectActivity, error) {
|
||||
items := make([]SubjectActivity, 0, len(rows))
|
||||
if len(rows) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
eventIDs := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
eventIDs = append(eventIDs, row.ID)
|
||||
}
|
||||
var resources []model.AuditEventResource
|
||||
if err := q.db.WithContext(ctx).Where("audit_event_id IN ? AND subject_visibility IN ?", eventIDs, []string{constants.AuditSubjectResult, constants.AuditSubjectDetail}).
|
||||
Order("audit_event_id ASC, sort_order ASC, id ASC").Find(&resources).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询主体可见关联资源失败")
|
||||
}
|
||||
allowed, err := authorize(ctx, resources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
related := make(map[uint][]SubjectResourceSummary, len(rows))
|
||||
for _, resource := range resources {
|
||||
if resource.ResourceID == nil || !allowed[resourceAccessKey(resource.ResourceType, *resource.ResourceID)] {
|
||||
continue
|
||||
}
|
||||
related[resource.AuditEventID] = append(related[resource.AuditEventID], SubjectResourceSummary{
|
||||
ResourceType: resource.ResourceType, ResourceID: *resource.ResourceID,
|
||||
ResourceKey: resource.ResourceKey, DisplayName: resource.DisplayName,
|
||||
})
|
||||
}
|
||||
for _, row := range rows {
|
||||
data, err := decodeObject(row.SubjectData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, SubjectActivity{ActionCode: row.ActionCode, ActionName: row.ActionName,
|
||||
SubjectSummary: row.SubjectSummary, SubjectData: data, Result: row.Result,
|
||||
OccurredAt: row.OccurredAt, RelatedResources: related[row.ID]})
|
||||
if items[len(items)-1].RelatedResources == nil {
|
||||
items[len(items)-1].RelatedResources = []SubjectResourceSummary{}
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (q *Query) enterpriseAllowedResourceIDs(ctx context.Context, resources []model.AuditEventResource, enterpriseID uint) (map[string]bool, error) {
|
||||
idsByType := collectResourceIDs(resources)
|
||||
allowed := make(map[string]bool)
|
||||
queries := []struct {
|
||||
resourceType string
|
||||
table string
|
||||
resourceID string
|
||||
}{
|
||||
{constants.AuditResourceIotCard, "tb_enterprise_card_authorization", "card_id"},
|
||||
{constants.AuditResourceDevice, "tb_enterprise_device_authorization", "device_id"},
|
||||
}
|
||||
for _, spec := range queries {
|
||||
ids := idsByType[spec.resourceType]
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
var visible []uint
|
||||
if err := q.db.WithContext(ctx).Table(spec.table).
|
||||
Where("enterprise_id = ? AND "+spec.resourceID+" IN ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID, ids).
|
||||
Pluck(spec.resourceID, &visible).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验企业关联资源授权失败")
|
||||
}
|
||||
markAllowedIDs(allowed, spec.resourceType, visible)
|
||||
}
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func (q *Query) agentAllowedResourceIDs(ctx context.Context, resources []model.AuditEventResource, shopIDs []uint) (map[string]bool, error) {
|
||||
idsByType := collectResourceIDs(resources)
|
||||
allowed := make(map[string]bool)
|
||||
queries := []struct {
|
||||
resourceType string
|
||||
table string
|
||||
condition string
|
||||
}{
|
||||
{constants.AuditResourceIotCard, "tb_iot_card", "shop_id IN ? AND deleted_at IS NULL"},
|
||||
{constants.AuditResourceDevice, "tb_device", "shop_id IN ? AND deleted_at IS NULL"},
|
||||
{constants.AuditResourceShop, "tb_shop", "id IN ? AND deleted_at IS NULL"},
|
||||
{constants.AuditResourceEnterprise, "tb_enterprise", "owner_shop_id IN ? AND deleted_at IS NULL"},
|
||||
{constants.AuditResourceExchangeOrder, "tb_exchange_order", "shop_id IN ? AND deleted_at IS NULL"},
|
||||
}
|
||||
for _, spec := range queries {
|
||||
if err := q.collectAgentAllowedIDs(ctx, allowed, spec.resourceType, spec.table, spec.condition, idsByType[spec.resourceType], shopIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if ids := idsByType[constants.AuditResourceAssetAllocationRecord]; len(ids) > 0 {
|
||||
var visible []uint
|
||||
if err := agentAllocationScope(q.db.WithContext(ctx).Table("tb_asset_allocation_record").Where("id IN ? AND deleted_at IS NULL", ids), shopIDs).
|
||||
Pluck("id", &visible).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验代理分配记录关联资源失败")
|
||||
}
|
||||
markAllowedIDs(allowed, constants.AuditResourceAssetAllocationRecord, visible)
|
||||
}
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func collectResourceIDs(resources []model.AuditEventResource) map[string][]uint {
|
||||
idsByType := make(map[string][]uint)
|
||||
for _, resource := range resources {
|
||||
if resource.ResourceID == nil {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseUint(*resource.ResourceID, 10, 64)
|
||||
if err == nil {
|
||||
idsByType[resource.ResourceType] = append(idsByType[resource.ResourceType], uint(id))
|
||||
}
|
||||
}
|
||||
return idsByType
|
||||
}
|
||||
|
||||
func (q *Query) collectAgentAllowedIDs(ctx context.Context, allowed map[string]bool, resourceType, table, condition string, ids, shopIDs []uint) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
var visible []uint
|
||||
if err := q.db.WithContext(ctx).Table(table).Where("id IN ?", ids).Where(condition, shopIDs).Pluck("id", &visible).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验代理关联资源范围失败")
|
||||
}
|
||||
markAllowedIDs(allowed, resourceType, visible)
|
||||
return nil
|
||||
}
|
||||
|
||||
func markAllowedIDs(allowed map[string]bool, resourceType string, ids []uint) {
|
||||
for _, id := range ids {
|
||||
allowed[resourceAccessKey(resourceType, strconv.FormatUint(uint64(id), 10))] = true
|
||||
}
|
||||
}
|
||||
|
||||
func resourceAccessKey(resourceType, resourceID string) string {
|
||||
return resourceType + ":" + resourceID
|
||||
}
|
||||
|
||||
func agentActivityResourceType(resourceType string) bool {
|
||||
switch resourceType {
|
||||
case constants.AuditResourceIotCard, constants.AuditResourceDevice, constants.AuditResourceAssetAllocationRecord,
|
||||
constants.AuditResourceExchangeOrder, constants.AuditResourceShop, constants.AuditResourceEnterprise:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func enterpriseActivityResourceType(resourceType string) bool {
|
||||
return resourceType == constants.AuditResourceIotCard || resourceType == constants.AuditResourceDevice
|
||||
}
|
||||
389
internal/query/integration/logs.go
Normal file
389
internal/query/integration/logs.go
Normal file
@@ -0,0 +1,389 @@
|
||||
// Package integration 提供 Integration Log 只读调查投影。
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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/sanitizer"
|
||||
pkgvalidator "github.com/break/junhong_cmp_fiber/pkg/validator"
|
||||
)
|
||||
|
||||
// ListFilter 定义 Integration Log 组合筛选。
|
||||
type ListFilter struct {
|
||||
CreatedFrom, CreatedTo *time.Time
|
||||
IntegrationID, Provider, Direction string
|
||||
Operation, Result, ResultCategory string
|
||||
ExternalID, ResourceType, ResourceID string
|
||||
ResourceKey, TriggerSource, TriggerScene string
|
||||
TriggerSeries, ProviderCode string
|
||||
RequestID, CorrelationID string
|
||||
StateChanged *bool
|
||||
HTTPStatus *int
|
||||
Page, PageSize int
|
||||
}
|
||||
|
||||
// ListPage 是按创建时间和主键稳定倒序的分页结果。
|
||||
type ListPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []ListItem `json:"items"`
|
||||
}
|
||||
|
||||
// ListItem 是 Integration Log 列表投影。
|
||||
type ListItem struct {
|
||||
IntegrationID string `json:"integration_id"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
Direction string `json:"direction"`
|
||||
DirectionName string `json:"direction_name"`
|
||||
Operation string `json:"operation"`
|
||||
OperationName string `json:"operation_name"`
|
||||
Resource ResourceView `json:"resource"`
|
||||
Result string `json:"result"`
|
||||
ResultName string `json:"result_name"`
|
||||
ResultCategory string `json:"result_category"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
StateChanged bool `json:"state_changed"`
|
||||
RequestID *string `json:"request_id"`
|
||||
CorrelationID *string `json:"correlation_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ResourceView 是外部交互直接主资源投影。
|
||||
type ResourceView struct {
|
||||
Type *string `json:"type"`
|
||||
ID *string `json:"id"`
|
||||
Key *string `json:"key"`
|
||||
}
|
||||
|
||||
// Detail 是按稳定 integration_id 返回的结构化详情。
|
||||
type Detail struct {
|
||||
Identity IdentityView `json:"identity"`
|
||||
Resource ResourceView `json:"resource"`
|
||||
Trigger TriggerView `json:"trigger"`
|
||||
Result ResultView `json:"result"`
|
||||
Content ContentView `json:"content"`
|
||||
Linkage LinkageView `json:"linkage"`
|
||||
Timestamps TimestampView `json:"timestamps"`
|
||||
Attempts []AttemptView `json:"attempts"`
|
||||
Fidelity FidelityView `json:"fidelity"`
|
||||
}
|
||||
|
||||
// AttemptView 是显式 trigger_series 下的单次技术尝试。
|
||||
type AttemptView struct {
|
||||
IntegrationID string `json:"integration_id"`
|
||||
Attempt int `json:"attempt"`
|
||||
Sent bool `json:"sent"`
|
||||
Result string `json:"result"`
|
||||
ResultName string `json:"result_name"`
|
||||
ResultCategory string `json:"result_category"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
StateChanged bool `json:"state_changed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// FidelityView 明确历史记录可关联能力,不推断缺失字段。
|
||||
type FidelityView struct {
|
||||
TriggerSeriesAvailable bool `json:"trigger_series_available"`
|
||||
CorrelationAvailable bool `json:"correlation_available"`
|
||||
ResourceIDAvailable bool `json:"resource_id_available"`
|
||||
ProviderMessageFidelity string `json:"provider_message_fidelity"`
|
||||
}
|
||||
|
||||
// IdentityView 是外部交互身份分组。
|
||||
type IdentityView struct {
|
||||
IntegrationID string `json:"integration_id"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
Direction string `json:"direction"`
|
||||
DirectionName string `json:"direction_name"`
|
||||
Operation string `json:"operation"`
|
||||
OperationName string `json:"operation_name"`
|
||||
ExternalID *string `json:"external_id"`
|
||||
}
|
||||
|
||||
// TriggerView 是外部交互触发分组。
|
||||
type TriggerView struct {
|
||||
Source *string `json:"source"`
|
||||
Scene *string `json:"scene"`
|
||||
Series *string `json:"series"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
// ResultView 是外部交互结果分组。
|
||||
type ResultView struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
HTTPStatus *int `json:"http_status"`
|
||||
ProviderCode *string `json:"provider_code"`
|
||||
ProviderMessage *string `json:"provider_message"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
StateChanged bool `json:"state_changed"`
|
||||
RecoveryStrategy *string `json:"recovery_strategy"`
|
||||
}
|
||||
|
||||
// ContentView 是已持久化安全摘要分组。
|
||||
type ContentView struct {
|
||||
RequestSummary map[string]any `json:"request_summary"`
|
||||
ResponseSummary map[string]any `json:"response_summary"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
}
|
||||
|
||||
// LinkageView 是外部交互关联分组。
|
||||
type LinkageView struct {
|
||||
RequestID *string `json:"request_id"`
|
||||
CorrelationID *string `json:"correlation_id"`
|
||||
AuditEventID *uint `json:"audit_event_id"`
|
||||
}
|
||||
|
||||
// TimestampView 是外部交互时间分组。
|
||||
type TimestampView struct {
|
||||
ScheduledAt *time.Time `json:"scheduled_at"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Query 提供平台 Integration Log 列表和详情读取。
|
||||
type Query struct{ db *gorm.DB }
|
||||
|
||||
// New 创建 Integration Log 调查 Query。
|
||||
func New(db *gorm.DB) *Query { return &Query{db: db} }
|
||||
|
||||
// List 查询受时间范围约束的 Integration Log 列表。
|
||||
func (q *Query) List(ctx context.Context, filter ListFilter) (*ListPage, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validFilter(filter) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||||
query := applyFilters(q.db.WithContext(ctx).Model(&model.IntegrationLog{}), filter)
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计外部交互日志失败")
|
||||
}
|
||||
rows := make([]model.IntegrationLog, 0, filter.PageSize)
|
||||
if err := query.Order("created_at DESC, id DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互日志失败")
|
||||
}
|
||||
items := make([]ListItem, len(rows))
|
||||
for i, row := range rows {
|
||||
items[i] = projectListItem(row)
|
||||
}
|
||||
return &ListPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
|
||||
}
|
||||
|
||||
// Get 使用稳定 integration_id 查询结构化详情。
|
||||
func (q *Query) Get(ctx context.Context, integrationID string) (*Detail, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if integrationID == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
var row model.IntegrationLog
|
||||
if err := q.db.WithContext(ctx).Where("integration_id = ?", integrationID).First(&row).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "外部交互日志不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互日志详情失败")
|
||||
}
|
||||
requestSummary, err := decodeObject(row.RequestSummary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responseSummary, err := decodeObject(row.ResponseSummary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata, err := decodeObject(row.Metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attempts, err := q.loadAttempts(ctx, row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
providerMessage, providerMessageFidelity := safeProviderMessage(row.ProviderMessage)
|
||||
return &Detail{
|
||||
Identity: IdentityView{IntegrationID: row.IntegrationID, Provider: row.Provider, ProviderName: constants.IntegrationProviderName(row.Provider), Direction: row.Direction, DirectionName: constants.IntegrationDirectionName(row.Direction), Operation: row.Operation, OperationName: constants.IntegrationOperationName(row.Operation), ExternalID: row.ExternalID},
|
||||
Resource: resourceView(row), Trigger: TriggerView{Source: row.TriggerSource, Scene: row.TriggerScene, Series: row.TriggerSeries, Attempt: row.Attempt},
|
||||
Result: ResultView{Code: row.Result, Name: constants.IntegrationResultName(row.Result), Category: constants.IntegrationResultCategory(row.Result), HTTPStatus: row.HTTPStatus, ProviderCode: row.ProviderCode, ProviderMessage: providerMessage, DurationMS: row.DurationMS, StateChanged: row.StateChanged, RecoveryStrategy: row.RecoveryStrategy},
|
||||
Content: ContentView{RequestSummary: requestSummary, ResponseSummary: responseSummary, Metadata: metadata, ContentHash: row.ContentHash},
|
||||
Linkage: LinkageView{RequestID: row.RequestID, CorrelationID: row.CorrelationID, AuditEventID: row.AuditEventID},
|
||||
Timestamps: TimestampView{ScheduledAt: row.ScheduledAt, StartedAt: row.StartedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt},
|
||||
Attempts: attempts,
|
||||
Fidelity: FidelityView{
|
||||
TriggerSeriesAvailable: row.TriggerSeries != nil && *row.TriggerSeries != "",
|
||||
CorrelationAvailable: row.CorrelationID != nil && *row.CorrelationID != "",
|
||||
ResourceIDAvailable: row.ResourceID != nil && *row.ResourceID != "",
|
||||
ProviderMessageFidelity: providerMessageFidelity,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *Query) loadAttempts(ctx context.Context, current model.IntegrationLog) ([]AttemptView, error) {
|
||||
rows := []model.IntegrationLog{current}
|
||||
if current.TriggerSeries != nil && *current.TriggerSeries != "" {
|
||||
if err := q.db.WithContext(ctx).Where("trigger_series = ?", *current.TriggerSeries).
|
||||
Order("attempt ASC, created_at ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互尝试序列失败")
|
||||
}
|
||||
}
|
||||
items := make([]AttemptView, len(rows))
|
||||
for index, row := range rows {
|
||||
category := constants.IntegrationResultCategory(row.Result)
|
||||
items[index] = AttemptView{
|
||||
IntegrationID: row.IntegrationID, Attempt: row.Attempt,
|
||||
Sent: category != constants.IntegrationResultCategoryNotSent,
|
||||
Result: row.Result, ResultName: constants.IntegrationResultName(row.Result), ResultCategory: category,
|
||||
DurationMS: row.DurationMS, StateChanged: row.StateChanged, CreatedAt: row.CreatedAt,
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (q *Query) authorize(ctx context.Context) error {
|
||||
if q == nil || q.db == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "外部交互调查能力未配置")
|
||||
}
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validFilter(filter ListFilter) bool {
|
||||
if filter.CreatedFrom == nil || filter.CreatedTo == nil || !filter.CreatedFrom.Before(*filter.CreatedTo) || filter.CreatedTo.Sub(*filter.CreatedFrom) > constants.IntegrationQueryMaxRange {
|
||||
return false
|
||||
}
|
||||
if filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
|
||||
return false
|
||||
}
|
||||
if filter.Direction != "" && filter.Direction != constants.IntegrationDirectionInbound && filter.Direction != constants.IntegrationDirectionOutbound {
|
||||
return false
|
||||
}
|
||||
if filter.Result != "" && constants.IntegrationResultName(filter.Result) == "" {
|
||||
return false
|
||||
}
|
||||
if filter.ResultCategory != "" && len(categoryResults(filter.ResultCategory)) == 0 {
|
||||
return false
|
||||
}
|
||||
return filter.HTTPStatus == nil || (*filter.HTTPStatus >= 100 && *filter.HTTPStatus <= 599)
|
||||
}
|
||||
|
||||
func applyFilters(query *gorm.DB, filter ListFilter) *gorm.DB {
|
||||
query = query.Where("created_at >= ? AND created_at < ?", filter.CreatedFrom.UTC(), filter.CreatedTo.UTC())
|
||||
for column, value := range map[string]string{
|
||||
"integration_id": filter.IntegrationID, "provider": filter.Provider, "direction": filter.Direction,
|
||||
"operation": filter.Operation, "result": filter.Result, "external_id": filter.ExternalID,
|
||||
"resource_type": filter.ResourceType, "resource_id": filter.ResourceID,
|
||||
"trigger_source": filter.TriggerSource, "trigger_scene": filter.TriggerScene, "trigger_series": filter.TriggerSeries,
|
||||
"provider_code": filter.ProviderCode, "request_id": filter.RequestID, "correlation_id": filter.CorrelationID,
|
||||
} {
|
||||
if value != "" {
|
||||
query = query.Where(column+" = ?", value)
|
||||
}
|
||||
}
|
||||
if filter.ResourceKey != "" {
|
||||
query = query.Where("resource_key IN ?", compatibleResourceKeys(filter.ResourceType, filter.ResourceKey))
|
||||
}
|
||||
if filter.ResultCategory != "" {
|
||||
query = query.Where("result IN ?", categoryResults(filter.ResultCategory))
|
||||
}
|
||||
if filter.StateChanged != nil {
|
||||
query = query.Where("state_changed = ?", *filter.StateChanged)
|
||||
}
|
||||
if filter.HTTPStatus != nil {
|
||||
query = query.Where("http_status = ?", *filter.HTTPStatus)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func compatibleResourceKeys(resourceType, resourceKey string) []string {
|
||||
keys := []string{resourceKey}
|
||||
if resourceType != constants.AssetTypeIotCard || !pkgvalidator.ValidateICCIDWithoutCarrier(resourceKey).Valid {
|
||||
return keys
|
||||
}
|
||||
sum := sha256.Sum256([]byte(resourceKey))
|
||||
return append(keys, "iccid-sha256:"+hex.EncodeToString(sum[:])[:32])
|
||||
}
|
||||
|
||||
func categoryResults(category string) []string {
|
||||
switch category {
|
||||
case constants.IntegrationResultCategoryProcessing:
|
||||
return []string{constants.IntegrationResultPending}
|
||||
case constants.IntegrationResultCategorySucceeded:
|
||||
return []string{constants.IntegrationResultSuccess}
|
||||
case constants.IntegrationResultCategoryIndeterminate:
|
||||
return []string{constants.IntegrationResultUnknown}
|
||||
case constants.IntegrationResultCategoryFailed:
|
||||
return []string{constants.IntegrationResultFailed, constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultConflict}
|
||||
case constants.IntegrationResultCategoryNotSent:
|
||||
return []string{constants.IntegrationResultIgnored, constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted, constants.IntegrationResultCancelled}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func projectListItem(row model.IntegrationLog) ListItem {
|
||||
return ListItem{IntegrationID: row.IntegrationID, Provider: row.Provider, ProviderName: constants.IntegrationProviderName(row.Provider), Direction: row.Direction, DirectionName: constants.IntegrationDirectionName(row.Direction), Operation: row.Operation, OperationName: constants.IntegrationOperationName(row.Operation), Resource: resourceView(row), Result: row.Result, ResultName: constants.IntegrationResultName(row.Result), ResultCategory: constants.IntegrationResultCategory(row.Result), DurationMS: row.DurationMS, StateChanged: row.StateChanged, RequestID: row.RequestID, CorrelationID: row.CorrelationID, CreatedAt: row.CreatedAt}
|
||||
}
|
||||
|
||||
func resourceView(row model.IntegrationLog) ResourceView {
|
||||
return ResourceView{Type: row.ResourceType, ID: row.ResourceID, Key: row.ResourceKey}
|
||||
}
|
||||
|
||||
func decodeObject(value datatypes.JSON) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
if len(value) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if err := sonic.Unmarshal(value, &result); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "解析外部交互结构化摘要失败")
|
||||
}
|
||||
sanitizer.RemoveForbiddenFields(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func safeProviderMessage(value *string) (*string, string) {
|
||||
if value == nil || *value == "" {
|
||||
return nil, "missing"
|
||||
}
|
||||
if len(*value) >= len("外部文本摘要") && (*value)[:len("外部文本摘要")] == "外部文本摘要" {
|
||||
return value, "historical_summary"
|
||||
}
|
||||
if len(*value) >= len(constants.IntegrationSafeMessagePrefix) && (*value)[:len(constants.IntegrationSafeMessagePrefix)] == constants.IntegrationSafeMessagePrefix {
|
||||
message := (*value)[len(constants.IntegrationSafeMessagePrefix):]
|
||||
return &message, "readable"
|
||||
}
|
||||
summary := sanitizer.TextSummary(*value)
|
||||
return &summary, "historical_redacted"
|
||||
}
|
||||
|
||||
func normalizePage(page, pageSize int) (int, int) {
|
||||
if page < 1 {
|
||||
page = constants.DefaultPage
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
171
internal/query/integration/overview.go
Normal file
171
internal/query/integration/overview.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// OverviewFilter 定义外部交互总览的受控筛选和时间粒度。
|
||||
type OverviewFilter struct {
|
||||
ListFilter
|
||||
Bucket string
|
||||
}
|
||||
|
||||
// Overview 是外部交互固定维度聚合结果。
|
||||
type Overview struct {
|
||||
Total int64 `json:"total"`
|
||||
AnomalyCount int64 `json:"anomaly_count"`
|
||||
UnknownCount int64 `json:"unknown_count"`
|
||||
StalePendingCount int64 `json:"stale_pending_count"`
|
||||
StateChangedCount int64 `json:"state_changed_count"`
|
||||
AverageDurationMS float64 `json:"average_duration_ms"`
|
||||
P95DurationMS float64 `json:"p95_duration_ms"`
|
||||
Results []ResultCount `json:"results"`
|
||||
Providers []NamedCount `json:"providers"`
|
||||
Directions []NamedCount `json:"directions"`
|
||||
Trend []TrendPoint `json:"trend"`
|
||||
}
|
||||
|
||||
// ResultCount 是原始结果及其派生类别计数。
|
||||
type ResultCount struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// NamedCount 是稳定编码、中文名称和数量。
|
||||
type NamedCount struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// TrendPoint 是固定时间桶内的结果类别趋势。
|
||||
type TrendPoint struct {
|
||||
BucketAt time.Time `json:"bucket_at"`
|
||||
Total int64 `json:"total"`
|
||||
Succeeded int64 `json:"succeeded"`
|
||||
Processing int64 `json:"processing"`
|
||||
Indeterminate int64 `json:"indeterminate"`
|
||||
Failed int64 `json:"failed"`
|
||||
NotSent int64 `json:"not_sent"`
|
||||
}
|
||||
|
||||
// Overview 查询指定时间范围的固定维度外部交互总览。
|
||||
func (q *Query) Overview(ctx context.Context, filter OverviewFilter) (*Overview, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter.Bucket == "" {
|
||||
filter.Bucket = "hour"
|
||||
}
|
||||
if !validFilter(filter.ListFilter) || (filter.Bucket != "hour" && filter.Bucket != "day") {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
base := applyFilters(q.db.WithContext(ctx).Model(&model.IntegrationLog{}), filter.ListFilter)
|
||||
result := &Overview{Results: []ResultCount{}, Providers: []NamedCount{}, Directions: []NamedCount{}, Trend: []TrendPoint{}}
|
||||
if err := loadOverviewMetrics(base, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadResultCounts(base, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadNamedCounts(base, "provider", result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadNamedCounts(base, "direction", result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadTrend(base, filter.Bucket, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadOverviewMetrics(query *gorm.DB, result *Overview) error {
|
||||
failed := categoryResults(constants.IntegrationResultCategoryFailed)
|
||||
var row struct {
|
||||
Total, AnomalyCount, UnknownCount, StalePendingCount, StateChangedCount int64
|
||||
AverageDurationMS, P95DurationMS float64
|
||||
}
|
||||
err := query.Select(`COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE result IN ? OR result = ?) AS anomaly_count,
|
||||
COUNT(*) FILTER (WHERE result = ?) AS unknown_count,
|
||||
COUNT(*) FILTER (WHERE result = ? AND created_at < ?) AS stale_pending_count,
|
||||
COUNT(*) FILTER (WHERE state_changed) AS state_changed_count,
|
||||
COALESCE(AVG(duration_ms), 0)::float8 AS average_duration_ms,
|
||||
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms), 0)::float8 AS p95_duration_ms`,
|
||||
failed, constants.IntegrationResultUnknown, constants.IntegrationResultUnknown,
|
||||
constants.IntegrationResultPending, time.Now().UTC().Add(-constants.IntegrationPendingStaleAfter)).Scan(&row).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互总览失败")
|
||||
}
|
||||
result.Total, result.AnomalyCount, result.UnknownCount = row.Total, row.AnomalyCount, row.UnknownCount
|
||||
result.StalePendingCount, result.StateChangedCount = row.StalePendingCount, row.StateChangedCount
|
||||
result.AverageDurationMS, result.P95DurationMS = row.AverageDurationMS, row.P95DurationMS
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadResultCounts(query *gorm.DB, result *Overview) error {
|
||||
var rows []struct {
|
||||
Code string
|
||||
Count int64
|
||||
}
|
||||
if err := query.Select("result AS code, COUNT(*) AS count").Group("result").Order("result ASC").Scan(&rows).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互结果分布失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
result.Results = append(result.Results, ResultCount{Code: row.Code, Name: constants.IntegrationResultName(row.Code), Category: constants.IntegrationResultCategory(row.Code), Count: row.Count})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadNamedCounts(query *gorm.DB, column string, result *Overview) error {
|
||||
var rows []struct {
|
||||
Code string
|
||||
Count int64
|
||||
}
|
||||
if err := query.Select(column + " AS code, COUNT(*) AS count").Group(column).Order(column + " ASC").Scan(&rows).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互维度分布失败")
|
||||
}
|
||||
items := make([]NamedCount, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
name := constants.IntegrationProviderName(row.Code)
|
||||
if column == "direction" {
|
||||
name = constants.IntegrationDirectionName(row.Code)
|
||||
}
|
||||
items = append(items, NamedCount{Code: row.Code, Name: name, Count: row.Count})
|
||||
}
|
||||
if column == "provider" {
|
||||
result.Providers = items
|
||||
} else {
|
||||
result.Directions = items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadTrend(query *gorm.DB, bucket string, result *Overview) error {
|
||||
failed, notSent := categoryResults(constants.IntegrationResultCategoryFailed), categoryResults(constants.IntegrationResultCategoryNotSent)
|
||||
return wrapTrendError(query.Select(`date_trunc(?, created_at) AS bucket_at, COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE result = ?) AS succeeded,
|
||||
COUNT(*) FILTER (WHERE result = ?) AS processing,
|
||||
COUNT(*) FILTER (WHERE result = ?) AS indeterminate,
|
||||
COUNT(*) FILTER (WHERE result IN ?) AS failed,
|
||||
COUNT(*) FILTER (WHERE result IN ?) AS not_sent`, bucket, constants.IntegrationResultSuccess,
|
||||
constants.IntegrationResultPending, constants.IntegrationResultUnknown, failed, notSent).
|
||||
Group("bucket_at").Order("bucket_at ASC").Scan(&result.Trend).Error)
|
||||
}
|
||||
|
||||
func wrapTrendError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互趋势失败")
|
||||
}
|
||||
@@ -144,6 +144,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
||||
if handlers.SystemConfig != nil {
|
||||
registerSystemConfigRoutes(authGroup, handlers.SystemConfig, doc, basePath)
|
||||
}
|
||||
if handlers.Audit != nil {
|
||||
registerAuditRoutes(authGroup, handlers.Audit, doc, basePath)
|
||||
}
|
||||
if handlers.WeCom != nil {
|
||||
registerWeComRoutes(authGroup, handlers.WeCom, doc, basePath)
|
||||
}
|
||||
|
||||
@@ -129,8 +129,8 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "GET", "/:identifier/operation-logs", handler.OperationLogs, RouteSpec{
|
||||
Summary: "资产操作审计日志",
|
||||
Description: "通过资产标识符查询审计日志,支持分页和操作类型/结果状态筛选。",
|
||||
Summary: "查询平台旧资产操作日志",
|
||||
Description: "仅超级管理员和平台账号可查询切换前旧资产日志;代理和企业必须使用独立资源活动接口。旧记录不接入统一审计时间线。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetOperationLogListRequest),
|
||||
Output: new(dto.AssetOperationLogListResponse),
|
||||
|
||||
73
internal/routes/audit.go
Normal file
73
internal/routes/audit.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
auditquery "github.com/break/junhong_cmp_fiber/internal/query/audit"
|
||||
integrationquery "github.com/break/junhong_cmp_fiber/internal/query/integration"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
// registerAuditRoutes 注册平台基础审计调查只读路由。
|
||||
func registerAuditRoutes(router fiber.Router, handler *admin.AuditHandler, doc *openapi.Generator, basePath string) {
|
||||
agent := router.Group("/agent/resource-activities")
|
||||
Register(agent, doc, basePath+"/agent/resource-activities", "GET", "/:resource_type/:identifier", handler.AgentResourceActivities, RouteSpec{
|
||||
Summary: "查询代理资源活动",
|
||||
Description: "resource_type/identifier 来自代理当前业务页面稳定字段;店铺范围只读取认证上下文。仅返回写入时生成的安全业务结论和白名单详情,越权与不存在同错。",
|
||||
Tags: []string{"资源活动"}, Input: new(dto.SubjectResourceActivityRequest), Output: new(auditquery.SubjectActivityPage), Auth: true,
|
||||
})
|
||||
enterprise := router.Group("/enterprise/resource-activities")
|
||||
Register(enterprise, doc, basePath+"/enterprise/resource-activities", "GET", "/:resource_type/:identifier", handler.EnterpriseResourceActivities, RouteSpec{
|
||||
Summary: "查询企业资源活动",
|
||||
Description: "仅支持企业当前有效授权的卡和设备;企业身份只读取认证上下文,授权撤销后立即不可读取。响应不包含平台操作者、风险、内部前后值或外部交互内容。",
|
||||
Tags: []string{"资源活动"}, Input: new(dto.SubjectResourceActivityRequest), Output: new(auditquery.SubjectActivityPage), Auth: true,
|
||||
})
|
||||
|
||||
audit := router.Group("/audit")
|
||||
groupPath := basePath + "/audit"
|
||||
|
||||
Register(audit, doc, groupPath, "GET", "/events", handler.ListEvents, RouteSpec{
|
||||
Summary: "查询全局审计事件",
|
||||
Description: "筛选值来自调查人员输入或其他调查节点的稳定引用;身份范围只读取认证上下文。固定按发生时间和事件ID倒序,不提供导出、修改或删除。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditEventListRequest), Output: new(auditquery.EventPage), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/events/:event_id", handler.GetEvent, RouteSpec{
|
||||
Summary: "查询审计事件详情",
|
||||
Description: "event_id 来自事件、资源、操作者或链路节点的 investigation_refs;返回全部资源快照和各资源 before/after。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditEventIDParams), Output: new(auditquery.EventView), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/actors/:kind/:id/events", handler.ListActorEvents, RouteSpec{
|
||||
Summary: "查询操作者行为时间线",
|
||||
Description: "kind/id 来自事件 actor_ref 或平台账号选择器;历史名称直接使用事件快照,不查询当前账号名称覆盖历史。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditActorEventsRequest), Output: new(auditquery.EventPage), Auth: true,
|
||||
})
|
||||
// 资源搜索静态路径必须先于资源动态时间线路径,避免被动态参数吞掉。
|
||||
Register(audit, doc, groupPath, "GET", "/resources/search", handler.SearchResources, RouteSpec{
|
||||
Summary: "精确搜索注册资源",
|
||||
Description: "卡支持 ICCID/VirtualNo,设备支持 VirtualNo/IMEI/SN,店铺、订单、退款使用各自稳定编号。当前资源不存在时仅按 Registry 白名单快照字段精确查找历史,不做任意 JSON 模糊搜索。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditResourceSearchRequest), Output: new(auditquery.ResourceSearchPage), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/resources/:resource_type/:resource_id/timeline", handler.ResourceTimeline, RouteSpec{
|
||||
Summary: "查询通用资源时间线",
|
||||
Description: "resource_type/resource_id 必须来自业务页面稳定字段、资源搜索结果或 investigation_refs。事件在资源作为 primary、affected 或 reference 时均会返回。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditResourceTimelineRequest), Output: new(auditquery.EventPage), Auth: true,
|
||||
})
|
||||
// Integration 总览静态路径必须先于动态详情路径,避免 overview 被当作 integration_id。
|
||||
Register(audit, doc, groupPath, "GET", "/integrations/overview", handler.IntegrationOverview, RouteSpec{
|
||||
Summary: "查询外部集成交互总览",
|
||||
Description: "筛选和时间范围来自调查输入或关联视角跳转,身份只来自认证上下文。总览区分成功、处理中、结果不确定、失败和未发送终态。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.IntegrationOverviewRequest), Output: new(integrationquery.Overview), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/integrations", handler.ListIntegrations, RouteSpec{
|
||||
Summary: "查询外部集成交互列表",
|
||||
Description: "组合筛选来自调查输入或关联视角稳定引用,固定按创建时间和记录ID倒序分页,不提供任意摘要搜索。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.IntegrationListRequest), Output: new(integrationquery.ListPage), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/integrations/:integration_id", handler.GetIntegration, RouteSpec{
|
||||
Summary: "查询外部集成交互详情",
|
||||
Description: "integration_id 来自列表、通知目标 target_key 或调查节点稳定引用;只展示结构化详情和显式尝试序列,不提供重试、补偿、确认、绑定、恢复、修改、删除或导出。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.IntegrationIDParams), Output: new(integrationquery.Detail), Auth: true,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,19 +3,25 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
accountauditapp "github.com/break/junhong_cmp_fiber/internal/application/accountaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
securityAudit accountauditapp.Writer
|
||||
accountStore *postgres.AccountStore
|
||||
accountRoleStore *postgres.AccountRoleStore
|
||||
rolePermStore *postgres.RolePermissionStore
|
||||
@@ -25,6 +31,12 @@ type Service struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// SetSecurityAudit 注入后台认证安全状态的统一审计接缝。
|
||||
func (s *Service) SetSecurityAudit(db *gorm.DB, writer accountauditapp.Writer) {
|
||||
s.db = db
|
||||
s.securityAudit = writer
|
||||
}
|
||||
|
||||
func New(
|
||||
accountStore *postgres.AccountStore,
|
||||
accountRoleStore *postgres.AccountRoleStore,
|
||||
@@ -54,15 +66,23 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询账号失败")
|
||||
}
|
||||
device := req.Device
|
||||
if device == "" {
|
||||
device = "web"
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(req.Password)); err != nil {
|
||||
s.logger.Warn("登录失败:密码错误", zap.String("username", req.Username), zap.String("ip", clientIP))
|
||||
return nil, errors.New(errors.CodeInvalidCredentials, "用户名或密码错误")
|
||||
appErr := errors.New(errors.CodeInvalidCredentials, "用户名或密码错误")
|
||||
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
if account.Status != 1 {
|
||||
s.logger.Warn("登录失败:账号已禁用", zap.String("username", req.Username), zap.Uint("user_id", account.ID))
|
||||
return nil, errors.New(errors.CodeAccountDisabled, "账号已禁用")
|
||||
appErr := errors.New(errors.CodeAccountDisabled, "账号已禁用")
|
||||
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
// 检查店铺状态(代理账号必须关联店铺且店铺必须启用)
|
||||
@@ -71,21 +91,21 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
s.logger.Warn("登录失败:关联店铺不存在", zap.String("username", req.Username), zap.Uint("shop_id", *account.ShopID))
|
||||
return nil, errors.New(errors.CodeShopNotFound, "关联店铺不存在")
|
||||
appErr := errors.New(errors.CodeShopNotFound, "关联店铺不存在")
|
||||
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "后台账号登录失败", constants.AuditResultFailed, device, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺失败")
|
||||
}
|
||||
if shop.Status != constants.StatusEnabled {
|
||||
s.logger.Warn("登录失败:关联店铺已禁用", zap.String("username", req.Username), zap.Uint("shop_id", *account.ShopID))
|
||||
return nil, errors.New(errors.CodeShopDisabled, "店铺已禁用,无法登录")
|
||||
appErr := errors.New(errors.CodeShopDisabled, "店铺已禁用,无法登录")
|
||||
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
}
|
||||
|
||||
device := req.Device
|
||||
if device == "" {
|
||||
device = "web"
|
||||
}
|
||||
|
||||
var shopID, enterpriseID uint
|
||||
if account.ShopID != nil {
|
||||
shopID = *account.ShopID
|
||||
@@ -106,8 +126,16 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
|
||||
|
||||
accessToken, refreshToken, err := s.tokenManager.GenerateTokenPair(ctx, tokenInfo)
|
||||
if err != nil {
|
||||
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "后台账号登录失败", constants.AuditResultFailed, device, err)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.writeSecurityAudit(ctx, account, accountauditapp.SecurityAudit{
|
||||
ActionCode: constants.AuditActionAuthLogin, Summary: "后台账号登录", Result: constants.AuditResultSuccess,
|
||||
ActorID: account.ID, ActorName: account.Username, AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":" + device,
|
||||
Authentication: authenticationData(account.ID, device, "password", "authenticated"),
|
||||
}); err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionAuthLogin, account.Username, contextRequestID(ctx), contextRequestID(ctx), strconv.Itoa(errors.CodeInternalError), err)
|
||||
}
|
||||
|
||||
permissions, menus, buttons, err := s.getUserPermissionsAndMenus(ctx, account.ID, account.UserType, device)
|
||||
if err != nil {
|
||||
@@ -139,6 +167,9 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, accessToken, refreshToken string) error {
|
||||
if err := s.tokenManager.RevokeToken(ctx, accessToken); err != nil {
|
||||
if account := s.loadAuditAccount(ctx); account != nil {
|
||||
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogout, "后台账号退出登录失败", constants.AuditResultFailed, "", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -147,6 +178,15 @@ func (s *Service) Logout(ctx context.Context, accessToken, refreshToken string)
|
||||
s.logger.Warn("撤销 refresh token 失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
if account := s.loadAuditAccount(ctx); account != nil {
|
||||
if err := s.writeSecurityAudit(ctx, account, accountauditapp.SecurityAudit{
|
||||
ActionCode: constants.AuditActionAuthLogout, Summary: "后台账号退出登录", Result: constants.AuditResultSuccess,
|
||||
ActorID: account.ID, ActorName: account.Username, AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":session",
|
||||
Authentication: authenticationData(account.ID, "", "token", "revoked"),
|
||||
}); err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionAuthLogout, account.Username, contextRequestID(ctx), contextRequestID(ctx), strconv.Itoa(errors.CodeInternalError), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -185,15 +225,34 @@ func (s *Service) ChangePassword(ctx context.Context, userID uint, oldPassword,
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(oldPassword)); err != nil {
|
||||
return errors.New(errors.CodeInvalidOldPassword, "旧密码错误")
|
||||
appErr := errors.New(errors.CodeInvalidOldPassword, "旧密码错误")
|
||||
s.recordSecurityFailure(ctx, account, constants.AuditActionAccountPasswordChanged, "拒绝修改账号密码", constants.AuditResultDenied, "", appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
s.recordSecurityFailure(ctx, account, constants.AuditActionAccountPasswordChanged, "修改账号密码失败", constants.AuditResultFailed, "", err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
|
||||
}
|
||||
|
||||
if err := s.accountStore.UpdatePassword(ctx, userID, string(hashedPassword), userID); err != nil {
|
||||
if s.db == nil || s.securityAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "后台认证审计接缝未配置")
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewAccountStore(tx, nil).UpdatePassword(ctx, userID, string(hashedPassword), userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.securityAudit.WriteAccountSecurity(ctx, tx, accountauditapp.SecurityAudit{
|
||||
ActionCode: constants.AuditActionAccountPasswordChanged, Summary: "修改账号密码", Result: constants.AuditResultSuccess,
|
||||
ActorID: account.ID, ActorName: account.Username, Account: account,
|
||||
AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":password",
|
||||
Authentication: authenticationData(account.ID, "", "password", "changed"),
|
||||
BeforeData: map[string]any{"credentials_configured": account.Password != ""},
|
||||
AfterData: map[string]any{"credentials_configured": true},
|
||||
})
|
||||
}); err != nil {
|
||||
s.recordSecurityFailure(ctx, account, constants.AuditActionAccountPasswordChanged, "修改账号密码失败", constants.AuditResultFailed, "", err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新密码失败")
|
||||
}
|
||||
|
||||
@@ -206,6 +265,63 @@ func (s *Service) ChangePassword(ctx context.Context, userID uint, oldPassword,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) writeSecurityAudit(ctx context.Context, account *model.Account, audit accountauditapp.SecurityAudit) error {
|
||||
if s.db == nil || s.securityAudit == nil || account == nil || account.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "后台认证审计接缝未配置")
|
||||
}
|
||||
audit.Account = account
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.securityAudit.WriteAccountSecurity(ctx, tx, audit)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordSecurityFailure(ctx context.Context, account *model.Account, actionCode, summary, result, device string, originalErr error) {
|
||||
if account == nil || account.ID == 0 {
|
||||
return
|
||||
}
|
||||
errorCode := strconv.Itoa(errors.CodeInternalError)
|
||||
if appErr, ok := originalErr.(*errors.AppError); ok {
|
||||
errorCode = strconv.Itoa(appErr.Code)
|
||||
}
|
||||
err := s.writeSecurityAudit(ctx, account, accountauditapp.SecurityAudit{
|
||||
ActionCode: actionCode, Summary: summary, Result: result, ErrorCode: errorCode, ErrorSummary: summary,
|
||||
ActorID: account.ID, ActorName: account.Username,
|
||||
AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":security",
|
||||
Authentication: authenticationData(account.ID, device, "password", result),
|
||||
})
|
||||
if err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(actionCode, account.Username, contextRequestID(ctx), contextRequestID(ctx), errorCode, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) loadAuditAccount(ctx context.Context) *model.Account {
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
return s.loadAuditAccountByID(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) loadAuditAccountByID(ctx context.Context, userID uint) *model.Account {
|
||||
if userID == 0 || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
var account model.Account
|
||||
if err := s.db.WithContext(ctx).Unscoped().First(&account, userID).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &account
|
||||
}
|
||||
|
||||
func authenticationData(accountID uint, device, method, state string) map[string]any {
|
||||
return map[string]any{"account_id": accountID, "device": device, "auth_method": method, "state": state}
|
||||
}
|
||||
|
||||
func contextRequestID(ctx context.Context) string {
|
||||
value := middleware.GetRequestIDFromContext(ctx)
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func (s *Service) getUserPermissions(ctx context.Context, userID uint) ([]string, error) {
|
||||
accountRoles, err := s.accountRoleStore.GetByAccountID(ctx, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,10 +4,12 @@ package client_auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||||
@@ -23,6 +25,7 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -52,6 +55,7 @@ type Service struct {
|
||||
logger *zap.Logger
|
||||
wechatCache kernel.CacheInterface
|
||||
customerBinding *customerBinding.Service
|
||||
accessAudit accessauditapp.Writer
|
||||
}
|
||||
|
||||
// New 创建 C 端认证服务实例
|
||||
@@ -68,6 +72,7 @@ func New(
|
||||
redisClient *redis.Client,
|
||||
logger *zap.Logger,
|
||||
binding *customerBinding.Service,
|
||||
accessAudit accessauditapp.Writer,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
@@ -83,6 +88,7 @@ func New(
|
||||
logger: logger,
|
||||
wechatCache: wechat.NewRedisCache(redisClient),
|
||||
customerBinding: binding,
|
||||
accessAudit: accessAudit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,24 +299,38 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindP
|
||||
if req == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
|
||||
}
|
||||
if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == nil {
|
||||
return nil, errors.New(errors.CodeAlreadyBoundPhone)
|
||||
appErr := errors.New(errors.CodeAlreadyBoundPhone)
|
||||
if customer, loadErr := s.customerStore.GetByID(ctx, customerID); loadErr == nil {
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
|
||||
}
|
||||
return nil, appErr
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
|
||||
}
|
||||
|
||||
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
customer, err := s.customerStore.GetByID(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
|
||||
}
|
||||
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
if existed, err := s.phoneStore.GetByPhone(ctx, req.Phone); err == nil {
|
||||
appErr := errors.New(errors.CodeAlreadyBoundPhone)
|
||||
if existed.CustomerID != customerID {
|
||||
return nil, errors.New(errors.CodePhoneAlreadyBound)
|
||||
appErr = errors.New(errors.CodePhoneAlreadyBound)
|
||||
}
|
||||
return nil, errors.New(errors.CodeAlreadyBoundPhone)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
|
||||
return nil, appErr
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
@@ -321,8 +341,38 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindP
|
||||
VerifiedAt: &now,
|
||||
Status: 1,
|
||||
}
|
||||
if err := s.phoneStore.Create(ctx, record); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败")
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(customer, customerID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.PersonalCustomerPhone{}).
|
||||
Where("customer_id = ? AND is_primary = ? AND status = ?", customerID, true, 1).Count(&count).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New(errors.CodeAlreadyBoundPhone)
|
||||
}
|
||||
var existed model.PersonalCustomerPhone
|
||||
if err := tx.Where("phone = ? AND status = ?", req.Phone, 1).First(&existed).Error; err == nil {
|
||||
if existed.CustomerID != customerID {
|
||||
return errors.New(errors.CodePhoneAlreadyBound)
|
||||
}
|
||||
return errors.New(errors.CodeAlreadyBoundPhone)
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
|
||||
}
|
||||
if err := tx.Create(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败")
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
|
||||
constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号", customer, record, nil,
|
||||
map[string]any{"phone": record.Phone}, "手机号已绑定", constants.AuditResultSuccess,
|
||||
))
|
||||
})
|
||||
if err != nil {
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, nil, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.BindPhoneResponse{
|
||||
@@ -336,41 +386,86 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha
|
||||
if req == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
|
||||
}
|
||||
customer, err := s.customerStore.GetByID(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
|
||||
}
|
||||
primary, err := s.phoneStore.GetPrimaryPhone(ctx, customerID)
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeOldPhoneMismatch)
|
||||
appErr := errors.New(errors.CodeOldPhoneMismatch)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号失败", customer, nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
if primary.Phone != req.OldPhone {
|
||||
return nil, errors.New(errors.CodeOldPhoneMismatch)
|
||||
appErr := errors.New(errors.CodeOldPhoneMismatch)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
if err := s.verificationService.VerifyCode(ctx, req.OldPhone, req.OldCode); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
if err := s.verificationService.VerifyCode(ctx, req.NewPhone, req.NewCode); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
}
|
||||
|
||||
if existed, err := s.phoneStore.GetByPhone(ctx, req.NewPhone); err == nil && existed.CustomerID != customerID {
|
||||
return nil, errors.New(errors.CodePhoneAlreadyBound)
|
||||
} else if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败")
|
||||
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := s.db.WithContext(ctx).Model(&model.PersonalCustomerPhone{}).
|
||||
Where("id = ? AND customer_id = ?", primary.ID, customerID).
|
||||
Updates(map[string]any{
|
||||
var beforeData map[string]any
|
||||
var failurePhone *model.PersonalCustomerPhone
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND customer_id = ? AND is_primary = ? AND status = ?", primary.ID, customerID, true, 1).
|
||||
First(primary).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeOldPhoneMismatch)
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
|
||||
}
|
||||
if primary.Phone != req.OldPhone {
|
||||
return errors.New(errors.CodeOldPhoneMismatch)
|
||||
}
|
||||
current := *primary
|
||||
failurePhone = ¤t
|
||||
beforeData = map[string]any{"phone": primary.Phone}
|
||||
var existed model.PersonalCustomerPhone
|
||||
if err := tx.Where("phone = ? AND status = ?", req.NewPhone, 1).First(&existed).Error; err == nil && existed.CustomerID != customerID {
|
||||
return errors.New(errors.CodePhoneAlreadyBound)
|
||||
} else if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败")
|
||||
}
|
||||
if err := tx.Model(primary).Updates(map[string]any{
|
||||
"phone": req.NewPhone,
|
||||
"verified_at": now,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新手机号失败")
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新手机号失败")
|
||||
}
|
||||
primary.Phone = req.NewPhone
|
||||
primary.VerifiedAt = &now
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
|
||||
constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号", customer, primary, beforeData,
|
||||
map[string]any{"phone": primary.Phone}, "手机号已更换", constants.AuditResultSuccess,
|
||||
))
|
||||
})
|
||||
if err != nil {
|
||||
if failurePhone == nil {
|
||||
failurePhone = primary
|
||||
}
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号失败", customer, failurePhone, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.ChangePhoneResponse{
|
||||
@@ -379,6 +474,56 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha
|
||||
}, nil
|
||||
}
|
||||
|
||||
func personalPhoneAudit(
|
||||
actionCode, summary string,
|
||||
customer *model.PersonalCustomer,
|
||||
phone *model.PersonalCustomerPhone,
|
||||
beforeData, afterData map[string]any,
|
||||
subjectSummary, result string,
|
||||
) accessauditapp.ChangeAudit {
|
||||
change := accessauditapp.ChangeAudit{
|
||||
ActionCode: actionCode, Summary: summary, Result: result,
|
||||
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
|
||||
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
|
||||
PersonalCustomer: customer, SubjectVisibility: constants.AuditSubjectDetail,
|
||||
SubjectSummary: subjectSummary, SubjectData: afterData,
|
||||
}
|
||||
if phone != nil && phone.ID != 0 {
|
||||
change.PersonalPhones = []accessauditapp.PersonalCustomerPhoneChange{{
|
||||
Phone: phone, BeforeData: beforeData, AfterData: afterData,
|
||||
}}
|
||||
}
|
||||
return change
|
||||
}
|
||||
|
||||
func (s *Service) recordPersonalFailure(
|
||||
ctx context.Context,
|
||||
actionCode, summary string,
|
||||
customer *model.PersonalCustomer,
|
||||
phone *model.PersonalCustomerPhone,
|
||||
originalErr error,
|
||||
) {
|
||||
if customer == nil || customer.ID == 0 {
|
||||
return
|
||||
}
|
||||
subjectSummary := "个人身份资料操作失败"
|
||||
change := personalPhoneAudit(actionCode, summary, customer, phone, nil, nil, subjectSummary, personalAuditFailureResult(originalErr))
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, change, originalErr)
|
||||
}
|
||||
|
||||
func personalAuditFailureResult(err error) string {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
switch appErr.Code {
|
||||
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeCustomerNotFound,
|
||||
errors.CodeAlreadyBoundPhone, errors.CodePhoneAlreadyBound, errors.CodeOldPhoneMismatch,
|
||||
errors.CodeVerificationCodeInvalid:
|
||||
return constants.AuditResultDenied
|
||||
}
|
||||
}
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
|
||||
// Logout A7 退出登录
|
||||
func (s *Service) Logout(ctx context.Context, customerID uint) (*dto.LogoutResponse, error) {
|
||||
redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
|
||||
@@ -509,13 +654,20 @@ func (s *Service) loginByOpenID(
|
||||
avatar string,
|
||||
appType string,
|
||||
) (uint, bool, error) {
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return 0, false, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
|
||||
}
|
||||
var (
|
||||
customerID uint
|
||||
isNewUser bool
|
||||
customerID uint
|
||||
isNewUser bool
|
||||
identityAudit *accessauditapp.ChangeAudit
|
||||
)
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
cid, created, findErr := s.findOrCreateCustomer(ctx, tx, appID, openID, unionID, nickname, avatar, appType)
|
||||
cid, created, change, findErr := s.findOrCreateCustomer(ctx, tx, appID, openID, unionID, nickname, avatar, appType)
|
||||
customerID = cid
|
||||
identityAudit = change
|
||||
isNewUser = created
|
||||
if findErr != nil {
|
||||
return findErr
|
||||
}
|
||||
@@ -523,11 +675,26 @@ func (s *Service) loginByOpenID(
|
||||
return bindErr
|
||||
}
|
||||
|
||||
customerID = cid
|
||||
isNewUser = created
|
||||
if identityAudit != nil {
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, *identityAudit)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if identityAudit != nil && customerID != 0 && !isNewUser {
|
||||
if identityAudit.ActionCode == constants.AuditActionPersonalCustomerProfileUpdated {
|
||||
identityAudit.Summary = "同步个人资料失败"
|
||||
identityAudit.SubjectSummary = "个人资料同步失败"
|
||||
} else {
|
||||
identityAudit.Summary = "同步个人微信主体失败"
|
||||
identityAudit.SubjectSummary = "微信登录身份同步失败"
|
||||
}
|
||||
identityAudit.Result = personalAuditFailureResult(err)
|
||||
identityAudit.SubjectData = nil
|
||||
identityAudit.PersonalOpenIDs = nil
|
||||
restorePersonalCustomerSnapshot(identityAudit)
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, *identityAudit, err)
|
||||
}
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
@@ -544,7 +711,7 @@ func (s *Service) findOrCreateCustomer(
|
||||
nickname string,
|
||||
avatar string,
|
||||
appType string,
|
||||
) (uint, bool, error) {
|
||||
) (uint, bool, *accessauditapp.ChangeAudit, error) {
|
||||
openidStore := postgres.NewPersonalCustomerOpenIDStore(tx)
|
||||
customerStore := postgres.NewPersonalCustomerStore(tx, s.redis)
|
||||
|
||||
@@ -552,26 +719,36 @@ func (s *Service) findOrCreateCustomer(
|
||||
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
|
||||
if getErr != nil {
|
||||
if getErr == gorm.ErrRecordNotFound {
|
||||
return 0, false, errors.New(errors.CodeCustomerNotFound)
|
||||
return 0, false, nil, errors.New(errors.CodeCustomerNotFound)
|
||||
}
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
|
||||
return 0, false, nil, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
|
||||
}
|
||||
if customer.Status == 0 {
|
||||
return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
|
||||
change := personalWechatAudit(customer, nil, nil, nil, appID, appType, constants.AuditResultDenied)
|
||||
return customer.ID, false, &change, errors.New(errors.CodeForbidden, "账号已被禁用")
|
||||
}
|
||||
|
||||
beforeData := personalCustomerProfileData(customer)
|
||||
changed := false
|
||||
if nickname != "" && customer.Nickname != nickname {
|
||||
customer.Nickname = nickname
|
||||
changed = true
|
||||
}
|
||||
if avatar != "" && customer.AvatarURL != avatar {
|
||||
customer.AvatarURL = avatar
|
||||
changed = true
|
||||
}
|
||||
var change *accessauditapp.ChangeAudit
|
||||
if changed {
|
||||
pending := personalProfileSyncAudit(customer, beforeData)
|
||||
change = &pending
|
||||
}
|
||||
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
|
||||
return customer.ID, false, change, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
|
||||
}
|
||||
return customer.ID, false, nil
|
||||
return customer.ID, false, change, nil
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, err, "查询 OpenID 记录失败")
|
||||
return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "查询 OpenID 记录失败")
|
||||
}
|
||||
|
||||
if unionID != "" {
|
||||
@@ -579,14 +756,16 @@ func (s *Service) findOrCreateCustomer(
|
||||
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
|
||||
if getErr != nil {
|
||||
if getErr == gorm.ErrRecordNotFound {
|
||||
return 0, false, errors.New(errors.CodeCustomerNotFound)
|
||||
return 0, false, nil, errors.New(errors.CodeCustomerNotFound)
|
||||
}
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
|
||||
return 0, false, nil, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
|
||||
}
|
||||
if customer.Status == 0 {
|
||||
return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
|
||||
change := personalWechatAudit(customer, nil, nil, nil, appID, appType, constants.AuditResultDenied)
|
||||
return customer.ID, false, &change, errors.New(errors.CodeForbidden, "账号已被禁用")
|
||||
}
|
||||
|
||||
beforeData := personalCustomerProfileData(customer)
|
||||
record := &model.PersonalCustomerOpenID{
|
||||
CustomerID: customer.ID,
|
||||
AppID: appID,
|
||||
@@ -594,8 +773,9 @@ func (s *Service) findOrCreateCustomer(
|
||||
UnionID: unionID,
|
||||
AppType: appType,
|
||||
}
|
||||
change := personalWechatAudit(customer, record, beforeData, nil, appID, appType, constants.AuditResultSuccess)
|
||||
if createErr := openidStore.Create(ctx, record); createErr != nil {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, createErr, "创建 OpenID 关联失败")
|
||||
return customer.ID, false, &change, errors.Wrap(errors.CodeInternalError, createErr, "创建 OpenID 关联失败")
|
||||
}
|
||||
|
||||
if nickname != "" && customer.Nickname != nickname {
|
||||
@@ -605,12 +785,14 @@ func (s *Service) findOrCreateCustomer(
|
||||
customer.AvatarURL = avatar
|
||||
}
|
||||
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
|
||||
change = personalWechatAudit(customer, record, beforeData, personalCustomerProfileData(customer), appID, appType, constants.AuditResultSuccess)
|
||||
return customer.ID, false, &change, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
|
||||
}
|
||||
|
||||
return customer.ID, false, nil
|
||||
change = personalWechatAudit(customer, record, beforeData, personalCustomerProfileData(customer), appID, appType, constants.AuditResultSuccess)
|
||||
return customer.ID, false, &change, nil
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, err, "按 UnionID 查询失败")
|
||||
return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "按 UnionID 查询失败")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,7 +804,7 @@ func (s *Service) findOrCreateCustomer(
|
||||
Status: 1,
|
||||
}
|
||||
if err := customerStore.Create(ctx, newCustomer); err != nil {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建客户失败")
|
||||
return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "创建客户失败")
|
||||
}
|
||||
|
||||
record := &model.PersonalCustomerOpenID{
|
||||
@@ -632,11 +814,64 @@ func (s *Service) findOrCreateCustomer(
|
||||
UnionID: unionID,
|
||||
AppType: appType,
|
||||
}
|
||||
change := personalWechatAudit(newCustomer, record, nil, personalCustomerProfileData(newCustomer), appID, appType, constants.AuditResultSuccess)
|
||||
if err := openidStore.Create(ctx, record); err != nil {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建 OpenID 关联失败")
|
||||
return newCustomer.ID, true, &change, errors.Wrap(errors.CodeInternalError, err, "创建 OpenID 关联失败")
|
||||
}
|
||||
|
||||
return newCustomer.ID, true, nil
|
||||
change = personalWechatAudit(newCustomer, record, nil, personalCustomerProfileData(newCustomer), appID, appType, constants.AuditResultSuccess)
|
||||
return newCustomer.ID, true, &change, nil
|
||||
}
|
||||
|
||||
func personalProfileSyncAudit(customer *model.PersonalCustomer, beforeData map[string]any) accessauditapp.ChangeAudit {
|
||||
return accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionPersonalCustomerProfileUpdated, Summary: "同步个人资料", Result: constants.AuditResultSuccess,
|
||||
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
|
||||
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
|
||||
PersonalCustomer: customer, BeforeData: beforeData, AfterData: personalCustomerProfileData(customer),
|
||||
SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: "个人资料已同步",
|
||||
SubjectData: personalCustomerProfileData(customer),
|
||||
}
|
||||
}
|
||||
|
||||
func personalWechatAudit(
|
||||
customer *model.PersonalCustomer,
|
||||
openID *model.PersonalCustomerOpenID,
|
||||
beforeData, afterData map[string]any,
|
||||
appID, appType, result string,
|
||||
) accessauditapp.ChangeAudit {
|
||||
change := accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionPersonalCustomerWechatIdentityUpdated, Summary: "同步个人微信主体", Result: result,
|
||||
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
|
||||
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
|
||||
PersonalCustomer: customer, BeforeData: beforeData, AfterData: afterData,
|
||||
SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: "微信登录身份已同步",
|
||||
SubjectData: map[string]any{"app_id": appID, "app_type": appType},
|
||||
}
|
||||
if openID != nil && openID.ID != 0 {
|
||||
change.PersonalOpenIDs = []accessauditapp.PersonalCustomerOpenIDChange{{
|
||||
OpenID: openID, AfterData: map[string]any{"app_id": openID.AppID, "app_type": openID.AppType},
|
||||
}}
|
||||
}
|
||||
return change
|
||||
}
|
||||
|
||||
func personalCustomerProfileData(customer *model.PersonalCustomer) map[string]any {
|
||||
return map[string]any{"nickname": customer.Nickname, "avatar_url": customer.AvatarURL}
|
||||
}
|
||||
|
||||
func restorePersonalCustomerSnapshot(change *accessauditapp.ChangeAudit) {
|
||||
if change.PersonalCustomer == nil || change.BeforeData == nil {
|
||||
return
|
||||
}
|
||||
customer := *change.PersonalCustomer
|
||||
if nickname, ok := change.BeforeData["nickname"].(string); ok {
|
||||
customer.Nickname = nickname
|
||||
}
|
||||
if avatarURL, ok := change.BeforeData["avatar_url"].(string); ok {
|
||||
customer.AvatarURL = avatarURL
|
||||
}
|
||||
change.PersonalCustomer = &customer
|
||||
}
|
||||
|
||||
// checkCardBoundToDevice 检查卡是否绑定了设备
|
||||
@@ -703,6 +938,9 @@ func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetTyp
|
||||
// 根据资产标识符查找或创建测试客户并直接签发 JWT,无需微信 OAuth
|
||||
// ⚠️ 仅限 logging.development=true 时由路由层暴露,严禁生产环境调用
|
||||
func (s *Service) DevLogin(ctx context.Context, identifier string) (string, uint, bool, error) {
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return "", 0, false, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
|
||||
}
|
||||
assetType, assetID, _, err := s.resolveAsset(ctx, identifier)
|
||||
if err != nil {
|
||||
return "", 0, false, err
|
||||
@@ -719,13 +957,18 @@ func (s *Service) DevLogin(ctx context.Context, identifier string) (string, uint
|
||||
devOpenID := "dev_test_" + identifier
|
||||
devAppID := "dev_test_app"
|
||||
|
||||
cid, created, findErr := s.findOrCreateCustomer(ctx, tx, devAppID, devOpenID, "", "测试用户", "", "dev")
|
||||
cid, created, identityAudit, findErr := s.findOrCreateCustomer(ctx, tx, devAppID, devOpenID, "", "测试用户", "", "dev")
|
||||
if findErr != nil {
|
||||
return findErr
|
||||
}
|
||||
if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil {
|
||||
return bindErr
|
||||
}
|
||||
if identityAudit != nil {
|
||||
if auditErr := s.accessAudit.WriteAccessChange(ctx, tx, *identityAudit); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
}
|
||||
customerID = cid
|
||||
isNewUser = created
|
||||
return nil
|
||||
|
||||
117
internal/service/device/batch_audit.go
Normal file
117
internal/service/device/batch_audit.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func (s *Service) appendCSVBatchAllocationAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
devices []*model.Device,
|
||||
succeededIDs []uint,
|
||||
failedItems []dto.AllocationDeviceFailedItem,
|
||||
targetShopID uint,
|
||||
) error {
|
||||
linkage := auditcontext.From(ctx)
|
||||
if s.auditWriter == nil || linkage.ActorKind != constants.AuditActorSystemTask ||
|
||||
linkage.ActorID != constants.TaskTypeDeviceImport || linkage.Source != constants.AuditSourceWorker ||
|
||||
linkage.CorrelationID == "" {
|
||||
return nil
|
||||
}
|
||||
devicesByID := make(map[uint]*model.Device, len(devices))
|
||||
for _, device := range devices {
|
||||
if device != nil {
|
||||
devicesByID[device.ID] = device
|
||||
}
|
||||
}
|
||||
rootEventID := stableBatchEventID("root", linkage.CorrelationID)
|
||||
children := make([]audit.AppendInput, 0, len(succeededIDs)+len(failedItems))
|
||||
for _, deviceID := range succeededIDs {
|
||||
if device := devicesByID[deviceID]; device != nil {
|
||||
children = append(children, deviceBatchChild(device, rootEventID, linkage.CorrelationID, targetShopID, true, ""))
|
||||
}
|
||||
}
|
||||
for _, item := range failedItems {
|
||||
if device := devicesByID[item.DeviceID]; device != nil {
|
||||
children = append(children, deviceBatchChild(device, rootEventID, linkage.CorrelationID, targetShopID, false, item.Reason))
|
||||
}
|
||||
}
|
||||
result := constants.AuditResultSuccess
|
||||
if len(succeededIDs) > 0 && len(failedItems) > 0 {
|
||||
result = constants.AuditResultPartial
|
||||
}
|
||||
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
|
||||
Root: audit.AppendInput{
|
||||
EventID: rootEventID, ActionCode: constants.AuditActionDeviceBatchAllocationCompleted,
|
||||
Summary: "设备CSV批量分配完成", Result: result,
|
||||
CorrelationID: linkage.CorrelationID,
|
||||
BatchTotal: len(succeededIDs) + len(failedItems), SuccessCount: len(succeededIDs), FailCount: len(failedItems),
|
||||
Metadata: map[string]any{"operation_type": constants.DeviceImportOperationAssignShop, "target_shop_id": targetShopID},
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceDeviceBatchTask, Key: linkage.CorrelationID, DisplayName: linkage.CorrelationID,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
|
||||
IdentitySnapshot: map[string]any{"task_no": linkage.CorrelationID, "operation_type": constants.DeviceImportOperationAssignShop},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
},
|
||||
Children: children,
|
||||
})
|
||||
}
|
||||
|
||||
func deviceBatchChild(
|
||||
device *model.Device,
|
||||
parentEventID string,
|
||||
correlationID string,
|
||||
targetShopID uint,
|
||||
succeeded bool,
|
||||
reason string,
|
||||
) audit.AppendInput {
|
||||
resourceID := strconv.FormatUint(uint64(device.ID), 10)
|
||||
before := map[string]any{"shop_id": device.ShopID, "status": device.Status}
|
||||
after := before
|
||||
result := constants.AuditResultFailed
|
||||
summary := "设备批量分配失败"
|
||||
if succeeded {
|
||||
after = map[string]any{"shop_id": targetShopID, "status": constants.DeviceStatusDistributed}
|
||||
result = constants.AuditResultSuccess
|
||||
summary = "设备批量分配成功"
|
||||
}
|
||||
return audit.AppendInput{
|
||||
EventID: stableBatchEventID("device", correlationID+":"+resourceID),
|
||||
ActionCode: constants.AuditActionDeviceBatchAllocationItem, Summary: summary,
|
||||
Result: result, ErrorSummary: reason, CorrelationID: correlationID, ParentEventID: parentEventID,
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceDevice, ID: &resourceID, Key: deviceAuditKey(device), DisplayName: deviceAuditKey(device),
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchItem,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI, "sn": device.SN,
|
||||
"shop_id": device.ShopID, "series_id": device.SeriesID, "generation": device.Generation,
|
||||
},
|
||||
BeforeData: before, AfterData: after,
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func stableBatchEventID(kind, key string) string {
|
||||
return "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("device-batch:"+kind+":"+key)).String()
|
||||
}
|
||||
|
||||
func deviceAuditKey(device *model.Device) string {
|
||||
for _, value := range []string{device.VirtualNo, device.IMEI, device.SN} {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return strconv.FormatUint(uint64(device.ID), 10)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
@@ -42,6 +43,7 @@ type Service struct {
|
||||
packageExpiryQuery *packageexpiry.Query
|
||||
observationSeriesEvents cardObservationApp.SeriesEventWriter
|
||||
observationSeries cardObservationApp.BestEffortSeriesDispatcher
|
||||
auditWriter *auditinfra.Writer
|
||||
}
|
||||
|
||||
// SetObservationSeriesEventWriter 注入设备停复机成功观测序列 Outbox Writer。
|
||||
@@ -168,6 +170,7 @@ func New(
|
||||
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
|
||||
enterpriseStore: enterpriseStore,
|
||||
packageExpiryQuery: packageexpiry.NewQuery(db),
|
||||
auditWriter: auditinfra.NewWriter(nil, nil),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,7 +681,10 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
|
||||
|
||||
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
|
||||
records := s.buildAllocationRecords(devices, deviceIDs, operatorShopID, targetShopID, operatorID, allocationNo, req.Remark)
|
||||
return txRecordStore.BatchCreate(ctx, records)
|
||||
if err := txRecordStore.BatchCreate(ctx, records); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendCSVBatchAllocationAudit(ctx, tx, devices, deviceIDs, failedItems, targetShopID)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -2,7 +2,9 @@ package enterprise
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -19,14 +22,23 @@ type Service struct {
|
||||
enterpriseStore *postgres.EnterpriseStore
|
||||
shopStore *postgres.ShopStore
|
||||
accountStore *postgres.AccountStore
|
||||
accessAudit accessauditapp.Writer
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, enterpriseStore *postgres.EnterpriseStore, shopStore *postgres.ShopStore, accountStore *postgres.AccountStore) *Service {
|
||||
// New 创建企业生命周期服务。
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
enterpriseStore *postgres.EnterpriseStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
accountStore *postgres.AccountStore,
|
||||
accessAudit accessauditapp.Writer,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
enterpriseStore: enterpriseStore,
|
||||
shopStore: shopStore,
|
||||
accountStore: accountStore,
|
||||
accessAudit: accessAudit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,52 +47,71 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateEnterpriseReq) (*dt
|
||||
if currentUserID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "企业审计接缝未配置")
|
||||
}
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: req.EnterpriseName, EnterpriseCode: req.EnterpriseCode, OwnerShopID: req.OwnerShopID,
|
||||
LegalPerson: req.LegalPerson, ContactName: req.ContactName, ContactPhone: req.ContactPhone,
|
||||
BusinessLicense: req.BusinessLicense, Province: req.Province, City: req.City,
|
||||
District: req.District, Address: req.Address, Status: constants.StatusEnabled,
|
||||
}
|
||||
enterprise.Creator = currentUserID
|
||||
enterprise.Updater = currentUserID
|
||||
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise {
|
||||
err := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
|
||||
return nil, err
|
||||
}
|
||||
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeAgent && req.OwnerShopID == nil {
|
||||
err := errors.New(errors.CodeForbidden, "代理账号不能创建平台主管企业")
|
||||
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.EnterpriseCode != "" {
|
||||
existing, _ := s.enterpriseStore.GetByCode(ctx, req.EnterpriseCode)
|
||||
if existing != nil {
|
||||
return nil, errors.New(errors.CodeEnterpriseCodeExists, "企业编号已存在")
|
||||
err := errors.New(errors.CodeEnterpriseCodeExists, "企业编号已存在")
|
||||
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
existingAccount, _ := s.accountStore.GetByPhone(ctx, req.LoginPhone)
|
||||
if existingAccount != nil {
|
||||
return nil, errors.New(errors.CodePhoneExists, "手机号已被使用")
|
||||
err := errors.New(errors.CodePhoneExists, "手机号已被使用")
|
||||
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ownerShop *model.Shop
|
||||
if req.OwnerShopID != nil {
|
||||
_, err := s.shopStore.GetByID(ctx, *req.OwnerShopID)
|
||||
if err := middleware.CanManageShop(ctx, *req.OwnerShopID); err != nil {
|
||||
err = errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
|
||||
return nil, err
|
||||
}
|
||||
var err error
|
||||
ownerShop, err = s.shopStore.GetByID(ctx, *req.OwnerShopID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeShopNotFound, "归属店铺不存在或无效")
|
||||
err = errors.New(errors.CodeShopNotFound, "归属店铺不存在或无效")
|
||||
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
|
||||
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, ownerShop, nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
var enterprise *model.Enterprise
|
||||
var account *model.Account
|
||||
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
enterprise = &model.Enterprise{
|
||||
EnterpriseName: req.EnterpriseName,
|
||||
EnterpriseCode: req.EnterpriseCode,
|
||||
OwnerShopID: req.OwnerShopID,
|
||||
LegalPerson: req.LegalPerson,
|
||||
ContactName: req.ContactName,
|
||||
ContactPhone: req.ContactPhone,
|
||||
BusinessLicense: req.BusinessLicense,
|
||||
Province: req.Province,
|
||||
City: req.City,
|
||||
District: req.District,
|
||||
Address: req.Address,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
enterprise.Creator = currentUserID
|
||||
enterprise.Updater = currentUserID
|
||||
|
||||
if err := tx.WithContext(ctx).Create(enterprise).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建企业失败")
|
||||
}
|
||||
@@ -100,18 +131,27 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateEnterpriseReq) (*dt
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建企业账号失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionEnterpriseCreated, Summary: "创建企业", OperatorID: currentUserID,
|
||||
Enterprise: enterprise, Shop: ownerShop,
|
||||
Accounts: []accessauditapp.AccountChange{{
|
||||
Account: account, Role: constants.AuditResourceRoleEnterpriseAccount,
|
||||
AfterData: map[string]any{"status": account.Status, "credentials_configured": true},
|
||||
}},
|
||||
AfterData: enterpriseProfileData(enterprise),
|
||||
})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
failureEnterprise := *enterprise
|
||||
failureEnterprise.ID = 0
|
||||
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", &failureEnterprise, ownerShop, nil, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ownerShopName := ""
|
||||
if enterprise.OwnerShopID != nil {
|
||||
if shop, err := s.shopStore.GetByID(ctx, *enterprise.OwnerShopID); err == nil {
|
||||
ownerShopName = shop.ShopName
|
||||
}
|
||||
if ownerShop != nil {
|
||||
ownerShopName = ownerShop.ShopName
|
||||
}
|
||||
|
||||
return &dto.CreateEnterpriseResp{
|
||||
@@ -140,28 +180,205 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateEnterpriseReq) (*dt
|
||||
|
||||
// Update 更新企业信息
|
||||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateEnterpriseRequest) (*model.Enterprise, error) {
|
||||
// 获取当前用户 ID
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "企业审计接缝未配置")
|
||||
}
|
||||
if err := middleware.CanManageEnterprise(ctx, id, s.enterpriseStore); err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
|
||||
// 查询企业
|
||||
var enterprise *model.Enterprise
|
||||
var before *model.Enterprise
|
||||
var ownerShop *model.Shop
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
locked, err := lockEnterprise(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeValue := *locked
|
||||
before = &beforeValue
|
||||
enterprise = locked
|
||||
ownerShop = loadEnterpriseOwnerShop(tx, locked.OwnerShopID)
|
||||
if req.EnterpriseCode != nil && *req.EnterpriseCode != locked.EnterpriseCode {
|
||||
var count int64
|
||||
if err := tx.Model(&model.Enterprise{}).Where("enterprise_code = ? AND id <> ?", *req.EnterpriseCode, id).Count(&count).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "检查企业编号失败")
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New(errors.CodeEnterpriseCodeExists, "企业编号已存在")
|
||||
}
|
||||
locked.EnterpriseCode = *req.EnterpriseCode
|
||||
}
|
||||
applyEnterpriseUpdate(locked, req)
|
||||
locked.Updater = currentUserID
|
||||
if err := tx.Save(locked).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业失败")
|
||||
}
|
||||
if !enterpriseProfileChanged(before, locked) {
|
||||
return nil
|
||||
}
|
||||
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionEnterpriseUpdated, Summary: "更新企业基础资料", OperatorID: currentUserID,
|
||||
Enterprise: locked, Shop: ownerShop,
|
||||
BeforeData: enterpriseProfileData(before), AfterData: enterpriseProfileData(locked),
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入企业更新审计失败")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if before != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionEnterpriseUpdated, "更新企业基础资料失败", before, ownerShop, nil, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return enterprise, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "企业审计接缝未配置")
|
||||
}
|
||||
if err := middleware.CanManageEnterprise(ctx, id, s.enterpriseStore); err != nil {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
|
||||
var before *model.Enterprise
|
||||
var ownerShop *model.Shop
|
||||
var accounts []*model.Account
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
enterprise, err := lockEnterprise(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeValue := *enterprise
|
||||
before = &beforeValue
|
||||
ownerShop = loadEnterpriseOwnerShop(tx, enterprise.OwnerShopID)
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("enterprise_id = ?", id).Find(&accounts).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询企业账号失败")
|
||||
}
|
||||
enterprise.Status = status
|
||||
enterprise.Updater = currentUserID
|
||||
if err := tx.Save(enterprise).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新企业状态失败")
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Account{}).
|
||||
Where("enterprise_id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"updater": currentUserID,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "同步更新企业账号状态失败")
|
||||
}
|
||||
if before.Status == status {
|
||||
return nil
|
||||
}
|
||||
accountChanges := make([]accessauditapp.AccountChange, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
accountChanges = append(accountChanges, accessauditapp.AccountChange{
|
||||
Account: account, Role: constants.AuditResourceRoleEnterpriseAccount,
|
||||
BeforeData: map[string]any{"status": account.Status}, AfterData: map[string]any{"status": status},
|
||||
})
|
||||
}
|
||||
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionEnterpriseStatusUpdated, Summary: "更新企业状态", OperatorID: currentUserID,
|
||||
Enterprise: enterprise, Shop: ownerShop, Accounts: accountChanges,
|
||||
BeforeData: map[string]any{"status": before.Status}, AfterData: map[string]any{"status": status},
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入企业状态审计失败")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if before != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionEnterpriseStatusUpdated, "更新企业状态失败", before, ownerShop, accounts, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdatePassword(ctx context.Context, id uint, password string) error {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "企业审计接缝未配置")
|
||||
}
|
||||
if err := middleware.CanManageEnterprise(ctx, id, s.enterpriseStore); err != nil {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
enterprise, err := s.enterpriseStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||||
return errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||||
}
|
||||
var ownerShop *model.Shop
|
||||
if enterprise.OwnerShopID != nil {
|
||||
ownerShop, _ = s.shopStore.GetByID(ctx, *enterprise.OwnerShopID)
|
||||
}
|
||||
|
||||
// 检查企业编号唯一性(如果修改了编号)
|
||||
if req.EnterpriseCode != nil && *req.EnterpriseCode != enterprise.EnterpriseCode {
|
||||
existing, err := s.enterpriseStore.GetByCode(ctx, *req.EnterpriseCode)
|
||||
if err == nil && existing != nil && existing.ID != id {
|
||||
return nil, errors.New(errors.CodeEnterpriseCodeExists, "企业编号已存在")
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
|
||||
s.recordFailure(ctx, constants.AuditActionEnterprisePasswordUpdated, "更新企业账号密码失败", enterprise, ownerShop, nil, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
var accounts []*model.Account
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
enterprise, err = lockEnterprise(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enterprise.EnterpriseCode = *req.EnterpriseCode
|
||||
ownerShop = loadEnterpriseOwnerShop(tx, enterprise.OwnerShopID)
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("enterprise_id = ?", id).Find(&accounts).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询企业账号失败")
|
||||
}
|
||||
if err := tx.Model(&model.Account{}).Where("enterprise_id = ?", id).Updates(map[string]interface{}{
|
||||
"password": string(hashedPassword), "updater": currentUserID,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业账号密码失败")
|
||||
}
|
||||
accountChanges := make([]accessauditapp.AccountChange, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
accountChanges = append(accountChanges, accessauditapp.AccountChange{
|
||||
Account: account, Role: constants.AuditResourceRoleEnterpriseAccount,
|
||||
BeforeData: map[string]any{"credentials_configured": account.Password != ""},
|
||||
AfterData: map[string]any{"credentials_configured": true, "state": "changed"},
|
||||
})
|
||||
}
|
||||
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionEnterprisePasswordUpdated, Summary: "更新企业账号密码", OperatorID: currentUserID,
|
||||
Enterprise: enterprise, Shop: ownerShop, Accounts: accountChanges,
|
||||
BeforeData: map[string]any{"credentials_configured": len(accounts) > 0},
|
||||
AfterData: map[string]any{"credentials_configured": len(accounts) > 0, "state": "changed"},
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入企业改密审计失败")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if enterprise != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionEnterprisePasswordUpdated, "更新企业账号密码失败", enterprise, ownerShop, accounts, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
func applyEnterpriseUpdate(enterprise *model.Enterprise, req *dto.UpdateEnterpriseRequest) {
|
||||
if req.EnterpriseName != nil {
|
||||
enterprise.EnterpriseName = *req.EnterpriseName
|
||||
}
|
||||
@@ -189,69 +406,81 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateEnterprise
|
||||
if req.Address != nil {
|
||||
enterprise.Address = *req.Address
|
||||
}
|
||||
|
||||
enterprise.Updater = currentUserID
|
||||
|
||||
if err := s.enterpriseStore.Update(ctx, enterprise); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return enterprise, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
func enterpriseProfileData(enterprise *model.Enterprise) map[string]any {
|
||||
return map[string]any{
|
||||
"enterprise_name": enterprise.EnterpriseName, "enterprise_code": enterprise.EnterpriseCode,
|
||||
"owner_shop_id": enterprise.OwnerShopID, "legal_person": enterprise.LegalPerson,
|
||||
"contact_name": enterprise.ContactName, "contact_phone": enterprise.ContactPhone,
|
||||
"business_license": enterprise.BusinessLicense, "province": enterprise.Province,
|
||||
"city": enterprise.City, "district": enterprise.District, "address": enterprise.Address,
|
||||
"status": enterprise.Status,
|
||||
}
|
||||
}
|
||||
|
||||
enterprise, err := s.enterpriseStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||||
func enterpriseProfileChanged(before, after *model.Enterprise) bool {
|
||||
return before.EnterpriseName != after.EnterpriseName || before.EnterpriseCode != after.EnterpriseCode ||
|
||||
before.LegalPerson != after.LegalPerson || before.ContactName != after.ContactName ||
|
||||
before.ContactPhone != after.ContactPhone || before.BusinessLicense != after.BusinessLicense ||
|
||||
before.Province != after.Province || before.City != after.City || before.District != after.District ||
|
||||
before.Address != after.Address
|
||||
}
|
||||
|
||||
func lockEnterprise(ctx context.Context, tx *gorm.DB, id uint) (*model.Enterprise, error) {
|
||||
var enterprise model.Enterprise
|
||||
query := middleware.ApplyOwnerShopFilter(ctx, tx.WithContext(ctx).Where("id = ?", id))
|
||||
if err := query.Clauses(clause.Locking{Strength: "UPDATE"}).First(&enterprise).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业失败")
|
||||
}
|
||||
return &enterprise, nil
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
enterprise.Status = status
|
||||
enterprise.Updater = currentUserID
|
||||
if err := tx.WithContext(ctx).Save(enterprise).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新企业状态失败")
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).Model(&model.Account{}).
|
||||
Where("enterprise_id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"updater": currentUserID,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "同步更新企业账号状态失败")
|
||||
}
|
||||
|
||||
func loadEnterpriseOwnerShop(tx *gorm.DB, ownerShopID *uint) *model.Shop {
|
||||
if ownerShopID == nil {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := tx.Unscoped().First(&shop, *ownerShopID).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &shop
|
||||
}
|
||||
|
||||
func (s *Service) UpdatePassword(ctx context.Context, id uint, password string) error {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
func (s *Service) recordFailure(
|
||||
ctx context.Context,
|
||||
actionCode, summary string,
|
||||
enterprise *model.Enterprise,
|
||||
ownerShop *model.Shop,
|
||||
accounts []*model.Account,
|
||||
originalErr error,
|
||||
) {
|
||||
accountChanges := make([]accessauditapp.AccountChange, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
accountChanges = append(accountChanges, accessauditapp.AccountChange{
|
||||
Account: account, Role: constants.AuditResourceRoleEnterpriseAccount,
|
||||
})
|
||||
}
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
|
||||
ActionCode: actionCode, Summary: summary, Result: enterpriseAuditFailureResult(originalErr),
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), Enterprise: enterprise, Shop: ownerShop,
|
||||
Accounts: accountChanges, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}, originalErr)
|
||||
}
|
||||
|
||||
_, err := s.enterpriseStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||||
func enterpriseAuditFailureResult(err error) string {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
switch appErr.Code {
|
||||
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeEnterpriseNotFound,
|
||||
errors.CodeEnterpriseCodeExists, errors.CodePhoneExists, errors.CodeShopNotFound:
|
||||
return constants.AuditResultDenied
|
||||
}
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Model(&model.Account{}).
|
||||
Where("enterprise_id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"password": string(hashedPassword),
|
||||
"updater": currentUserID,
|
||||
}).Error
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(ctx context.Context, id uint) (*model.Enterprise, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -12,26 +13,33 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type AuthorizationService struct {
|
||||
db *gorm.DB
|
||||
enterpriseStore *postgres.EnterpriseStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
authorizationStore *postgres.EnterpriseCardAuthorizationStore
|
||||
logger *zap.Logger
|
||||
accessAudit accessauditapp.Writer
|
||||
}
|
||||
|
||||
func NewAuthorizationService(
|
||||
db *gorm.DB,
|
||||
enterpriseStore *postgres.EnterpriseStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
authorizationStore *postgres.EnterpriseCardAuthorizationStore,
|
||||
logger *zap.Logger,
|
||||
accessAudit accessauditapp.Writer,
|
||||
) *AuthorizationService {
|
||||
return &AuthorizationService{
|
||||
db: db,
|
||||
enterpriseStore: enterpriseStore,
|
||||
iotCardStore: iotCardStore,
|
||||
authorizationStore: authorizationStore,
|
||||
logger: logger,
|
||||
accessAudit: accessAudit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +413,9 @@ func (s *AuthorizationService) UpdateRecordRemark(ctx context.Context, id uint,
|
||||
if userID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "用户信息无效")
|
||||
}
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "企业卡授权审计接缝未配置")
|
||||
}
|
||||
|
||||
record, err := s.authorizationStore.GetByIDWithJoin(ctx, id)
|
||||
if err != nil {
|
||||
@@ -420,23 +431,97 @@ func (s *AuthorizationService) UpdateRecordRemark(ctx context.Context, id uint,
|
||||
case constants.UserTypeAgent:
|
||||
// 代理用户: 只能修改自己创建的授权记录
|
||||
if record.AuthorizedBy != userID {
|
||||
return nil, errors.New(errors.CodeForbidden, "只能修改自己创建的授权记录备注")
|
||||
err := errors.New(errors.CodeForbidden, "只能修改自己创建的授权记录备注")
|
||||
s.recordRemarkFailure(ctx, record, err)
|
||||
return nil, err
|
||||
}
|
||||
case constants.UserTypeEnterprise:
|
||||
// 企业用户: 禁止修改授权记录备注
|
||||
return nil, errors.New(errors.CodeForbidden, "企业用户不允许修改授权记录备注")
|
||||
err := errors.New(errors.CodeForbidden, "企业用户不允许修改授权记录备注")
|
||||
s.recordRemarkFailure(ctx, record, err)
|
||||
return nil, err
|
||||
default:
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限修改授权记录备注")
|
||||
}
|
||||
|
||||
if err := s.authorizationStore.UpdateRemarkWithConstraint(ctx, id, remark, record.AuthorizedBy); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "授权记录不存在")
|
||||
}
|
||||
err := errors.New(errors.CodeForbidden, "无权限修改授权记录备注")
|
||||
s.recordRemarkFailure(ctx, record, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetRecordDetail(ctx, id)
|
||||
var enterprise model.Enterprise
|
||||
var card model.IotCard
|
||||
var auth model.EnterpriseCardAuthorization
|
||||
var ownerShop *model.Shop
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&auth, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "授权记录不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败")
|
||||
}
|
||||
if userType == constants.UserTypeAgent && auth.AuthorizedBy != userID {
|
||||
return errors.New(errors.CodeForbidden, "只能修改自己创建的授权记录备注")
|
||||
}
|
||||
if err := tx.First(&enterprise, auth.EnterpriseID).Error; err != nil {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
if err := tx.First(&card, auth.CardID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询授权卡失败")
|
||||
}
|
||||
if enterprise.OwnerShopID != nil {
|
||||
var shop model.Shop
|
||||
if err := tx.Unscoped().First(&shop, *enterprise.OwnerShopID).Error; err == nil {
|
||||
ownerShop = &shop
|
||||
}
|
||||
}
|
||||
beforeRemark := auth.Remark
|
||||
if beforeRemark == remark {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Model(&model.EnterpriseCardAuthorization{}).Where("id = ?", auth.ID).Update("remark", remark).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新授权备注失败")
|
||||
}
|
||||
auth.Remark = remark
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionEnterpriseCardRemarkUpdated, Summary: "更新企业卡授权备注",
|
||||
OperatorID: userID, Enterprise: &enterprise, Shop: ownerShop,
|
||||
Cards: []accessauditapp.IotCardChange{{
|
||||
Card: &card, Relation: constants.AuditResourceRelationReference,
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
CardAuthorizations: []accessauditapp.EnterpriseCardAuthorizationChange{{
|
||||
Authorization: &auth,
|
||||
BeforeData: map[string]any{"remark": beforeRemark}, AfterData: map[string]any{"remark": remark},
|
||||
}},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
s.recordRemarkFailure(ctx, record, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := s.GetRecordDetail(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationService) recordRemarkFailure(ctx context.Context, record *postgres.AuthorizationWithJoin, originalErr error) {
|
||||
if record == nil {
|
||||
return
|
||||
}
|
||||
enterprise, _ := s.enterpriseStore.GetByID(ctx, record.EnterpriseID)
|
||||
card, _ := s.iotCardStore.GetByID(ctx, record.CardID)
|
||||
auth := &model.EnterpriseCardAuthorization{
|
||||
ID: record.ID, EnterpriseID: record.EnterpriseID, CardID: record.CardID,
|
||||
AuthorizedBy: record.AuthorizedBy, AuthorizerType: record.AuthorizerType,
|
||||
AuthorizedAt: record.AuthorizedAt, RevokedBy: record.RevokedBy, RevokedAt: record.RevokedAt, Remark: record.Remark,
|
||||
}
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionEnterpriseCardRemarkUpdated, Summary: "更新企业卡授权备注失败",
|
||||
Result: enterpriseCardFailureResult(originalErr), OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||
Enterprise: enterprise, Cards: []accessauditapp.IotCardChange{{Card: card}},
|
||||
CardAuthorizations: []accessauditapp.EnterpriseCardAuthorizationChange{{Authorization: auth}},
|
||||
}, originalErr)
|
||||
}
|
||||
|
||||
func parseDate(dateStr string) (time.Time, error) {
|
||||
|
||||
@@ -2,8 +2,10 @@ package enterprise_card
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"time"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -18,6 +21,7 @@ type Service struct {
|
||||
enterpriseStore *postgres.EnterpriseStore
|
||||
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
accessAudit accessauditapp.Writer
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -25,12 +29,14 @@ func New(
|
||||
enterpriseStore *postgres.EnterpriseStore,
|
||||
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
accessAudit accessauditapp.Writer,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
enterpriseStore: enterpriseStore,
|
||||
enterpriseCardAuthStore: enterpriseCardAuthStore,
|
||||
iotCardStore: iotCardStore,
|
||||
accessAudit: accessAudit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,10 +210,20 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "企业卡授权审计接缝未配置")
|
||||
}
|
||||
if err := validateEnterpriseCardActor(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||||
}
|
||||
ownerShop := s.loadOwnerShop(ctx, enterprise.OwnerShopID)
|
||||
|
||||
iccids, err := s.resolveICCIDsForAllocate(ctx, req)
|
||||
if err != nil {
|
||||
@@ -231,6 +247,14 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
|
||||
cardIDToICCID[card.IotCardID] = card.ICCID
|
||||
allCandidateIDs = append(allCandidateIDs, card.IotCardID)
|
||||
}
|
||||
auditCardList, err := s.iotCardStore.GetByIDs(ctx, allCandidateIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡审计快照失败")
|
||||
}
|
||||
cardIDMap := make(map[uint]*model.IotCard, len(auditCardList))
|
||||
for _, card := range auditCardList {
|
||||
cardIDMap[card.ID] = card
|
||||
}
|
||||
|
||||
// 检测已被其他企业授权的卡,阻止重复授权
|
||||
conflictAuths, err := s.enterpriseCardAuthStore.GetConflictingAuthsByCardIDs(ctx, enterpriseID, allCandidateIDs)
|
||||
@@ -239,7 +263,12 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
|
||||
}
|
||||
|
||||
cardIDsToAllocate := make([]uint, 0, len(allCandidateIDs))
|
||||
seenAllocate := make(map[uint]struct{}, len(allCandidateIDs))
|
||||
for _, cardID := range allCandidateIDs {
|
||||
if _, seen := seenAllocate[cardID]; seen {
|
||||
continue
|
||||
}
|
||||
seenAllocate[cardID] = struct{}{}
|
||||
if _, conflict := conflictAuths[cardID]; conflict {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedItem{
|
||||
ICCID: cardIDToICCID[cardID],
|
||||
@@ -274,12 +303,41 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
|
||||
}
|
||||
|
||||
if len(auths) > 0 {
|
||||
if err := s.enterpriseCardAuthStore.BatchCreate(ctx, auths); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建授权记录失败")
|
||||
auditResult := constants.AuditResultSuccess
|
||||
if resp.FailCount > 0 {
|
||||
auditResult = constants.AuditResultPartial
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.CreateInBatches(auths, 100).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建授权记录失败")
|
||||
}
|
||||
cards := make([]accessauditapp.IotCardChange, 0, len(auths))
|
||||
authorizations := make([]accessauditapp.EnterpriseCardAuthorizationChange, 0, len(auths))
|
||||
for _, auth := range auths {
|
||||
card := cardIDMap[auth.CardID]
|
||||
cards = append(cards, accessauditapp.IotCardChange{
|
||||
Card: card, BeforeData: map[string]any{"enterprise_id": nil, "authorized": false},
|
||||
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "卡已授权给企业",
|
||||
})
|
||||
authorizations = append(authorizations, accessauditapp.EnterpriseCardAuthorizationChange{
|
||||
Authorization: auth, AfterData: map[string]any{"authorized": true, "remark": auth.Remark},
|
||||
})
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionEnterpriseCardsAllocated, Summary: "向企业授权卡",
|
||||
Result: auditResult, OperatorID: currentUserID, Enterprise: enterprise, Shop: ownerShop,
|
||||
Cards: cards, CardAuthorizations: authorizations,
|
||||
BeforeData: map[string]any{"authorized_card_count": 0},
|
||||
AfterData: map[string]any{"authorized_card_count": len(auths)},
|
||||
})
|
||||
}); err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionEnterpriseCardsAllocated, "向企业授权卡失败", enterprise, ownerShop, cardChanges(cardIDMap, allCandidateIDs), err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
resp.SuccessCount = len(cardIDsToAllocate)
|
||||
resp.SuccessCount = len(auths)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -289,10 +347,20 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "企业卡授权审计接缝未配置")
|
||||
}
|
||||
if err := validateEnterpriseCardActor(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||||
}
|
||||
ownerShop := s.loadOwnerShop(ctx, enterprise.OwnerShopID)
|
||||
|
||||
iccids, err := s.resolveICCIDsForRecall(ctx, enterpriseID, req)
|
||||
if err != nil {
|
||||
@@ -324,6 +392,7 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
|
||||
}
|
||||
|
||||
cardIDsToRecall := make([]uint, 0)
|
||||
seenRecall := make(map[uint]struct{}, len(iccids))
|
||||
for _, iccid := range iccids {
|
||||
card, exists := cardMap[iccid]
|
||||
if !exists {
|
||||
@@ -340,20 +409,130 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
|
||||
})
|
||||
continue
|
||||
}
|
||||
if _, seen := seenRecall[card.ID]; seen {
|
||||
continue
|
||||
}
|
||||
seenRecall[card.ID] = struct{}{}
|
||||
cardIDsToRecall = append(cardIDsToRecall, card.ID)
|
||||
}
|
||||
|
||||
if len(cardIDsToRecall) > 0 {
|
||||
if err := s.enterpriseCardAuthStore.BatchUpdateStatus(ctx, enterpriseID, cardIDsToRecall, 0); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "回收授权失败")
|
||||
var recalled []*model.EnterpriseCardAuthorization
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("enterprise_id = ? AND card_id IN ? AND revoked_at IS NULL", enterpriseID, cardIDsToRecall).
|
||||
Find(&recalled).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询有效卡授权失败")
|
||||
}
|
||||
if len(recalled) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
ids := make([]uint, 0, len(recalled))
|
||||
cards := make([]accessauditapp.IotCardChange, 0, len(recalled))
|
||||
authorizations := make([]accessauditapp.EnterpriseCardAuthorizationChange, 0, len(recalled))
|
||||
for _, auth := range recalled {
|
||||
ids = append(ids, auth.ID)
|
||||
before := *auth
|
||||
auth.RevokedBy = ¤tUserID
|
||||
auth.RevokedAt = &now
|
||||
cards = append(cards, accessauditapp.IotCardChange{
|
||||
Card: cardIDMap[auth.CardID],
|
||||
BeforeData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
|
||||
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": false},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "卡授权已回收",
|
||||
})
|
||||
authorizations = append(authorizations, accessauditapp.EnterpriseCardAuthorizationChange{
|
||||
Authorization: auth,
|
||||
BeforeData: map[string]any{"revoked_by": before.RevokedBy, "revoked_at": before.RevokedAt},
|
||||
AfterData: map[string]any{"revoked_by": currentUserID, "revoked_at": now},
|
||||
})
|
||||
}
|
||||
if err := tx.Model(&model.EnterpriseCardAuthorization{}).Where("id IN ? AND revoked_at IS NULL", ids).
|
||||
Updates(map[string]any{"revoked_by": currentUserID, "revoked_at": now}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "回收卡授权失败")
|
||||
}
|
||||
result := constants.AuditResultSuccess
|
||||
if len(resp.FailedItems) > 0 {
|
||||
result = constants.AuditResultPartial
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionEnterpriseCardsRecalled, Summary: "回收企业卡授权",
|
||||
Result: result, OperatorID: currentUserID, Enterprise: enterprise, Shop: ownerShop,
|
||||
Cards: cards, CardAuthorizations: authorizations,
|
||||
BeforeData: map[string]any{"authorized_card_count": len(recalled)},
|
||||
AfterData: map[string]any{"authorized_card_count": 0},
|
||||
})
|
||||
}); err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionEnterpriseCardsRecalled, "回收企业卡授权失败", enterprise, ownerShop, cardChanges(cardIDMap, cardIDsToRecall), err)
|
||||
return nil, err
|
||||
}
|
||||
resp.SuccessCount = len(recalled)
|
||||
}
|
||||
|
||||
resp.SuccessCount = len(cardIDsToRecall)
|
||||
resp.FailCount = len(resp.FailedItems)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func validateEnterpriseCardActor(ctx context.Context) error {
|
||||
switch middleware.GetUserTypeFromContext(ctx) {
|
||||
case constants.UserTypeSuperAdmin, constants.UserTypePlatform, constants.UserTypeAgent:
|
||||
return nil
|
||||
default:
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) loadOwnerShop(ctx context.Context, ownerShopID *uint) *model.Shop {
|
||||
if ownerShopID == nil {
|
||||
return nil
|
||||
}
|
||||
shop, err := postgres.NewShopStore(s.db, nil).GetByID(ctx, *ownerShopID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return shop
|
||||
}
|
||||
|
||||
func cardChanges(cardMap map[uint]*model.IotCard, ids []uint) []accessauditapp.IotCardChange {
|
||||
changes := make([]accessauditapp.IotCardChange, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if card := cardMap[id]; card != nil {
|
||||
changes = append(changes, accessauditapp.IotCardChange{Card: card})
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func (s *Service) recordFailure(
|
||||
ctx context.Context,
|
||||
actionCode, summary string,
|
||||
enterprise *model.Enterprise,
|
||||
ownerShop *model.Shop,
|
||||
cards []accessauditapp.IotCardChange,
|
||||
originalErr error,
|
||||
) {
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
|
||||
ActionCode: actionCode, Summary: summary, Result: enterpriseCardFailureResult(originalErr),
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), Enterprise: enterprise, Shop: ownerShop,
|
||||
Cards: cards, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}, originalErr)
|
||||
}
|
||||
|
||||
func enterpriseCardFailureResult(err error) string {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
switch appErr.Code {
|
||||
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeEnterpriseNotFound,
|
||||
errors.CodeIotCardNotFound, errors.CodeIotCardStatusNotAllowed, errors.CodeCannotAuthorizeToOthersEnterprise,
|
||||
errors.CodeCannotAuthorizeOthersCard, errors.CodeCannotAuthorizeBoundCard, errors.CodeCardAlreadyAuthorized,
|
||||
errors.CodeCardNotAuthorized, errors.CodeCannotRevokeOthersAuthorization:
|
||||
return constants.AuditResultDenied
|
||||
}
|
||||
}
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
|
||||
func (s *Service) ListCards(ctx context.Context, enterpriseID uint, req *dto.EnterpriseCardListReq) (*dto.EnterpriseCardPageResult, error) {
|
||||
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -24,6 +27,7 @@ type Service struct {
|
||||
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
|
||||
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
|
||||
logger *zap.Logger
|
||||
accessAudit accessauditapp.Writer
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -34,6 +38,7 @@ func New(
|
||||
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore,
|
||||
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore,
|
||||
logger *zap.Logger,
|
||||
accessAudit accessauditapp.Writer,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
@@ -43,6 +48,7 @@ func New(
|
||||
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
|
||||
enterpriseCardAuthStore: enterpriseCardAuthStore,
|
||||
logger: logger,
|
||||
accessAudit: accessAudit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +58,26 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
|
||||
if currentUserID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
// 验证企业存在
|
||||
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||||
if err := validateAllocateDevicesRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "企业设备授权审计接缝未配置")
|
||||
}
|
||||
if err := validateEnterpriseDeviceActor(ctx); err != nil {
|
||||
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, err)
|
||||
return nil, err
|
||||
}
|
||||
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
|
||||
permissionErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, permissionErr)
|
||||
return nil, permissionErr
|
||||
}
|
||||
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||||
}
|
||||
ownerShop := loadEnterpriseDeviceOwnerShop(ctx, s.db, enterprise.OwnerShopID)
|
||||
|
||||
// 根据选取模式解析候选设备号列表
|
||||
deviceNos, err := s.resolveDeviceNosForAllocate(ctx, req)
|
||||
@@ -67,7 +87,7 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
|
||||
|
||||
// 查询所有设备
|
||||
var devices []model.Device
|
||||
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
|
||||
if err := enterpriseDeviceQuery(ctx, s.db).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
|
||||
}
|
||||
|
||||
@@ -93,172 +113,221 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
|
||||
AuthorizedDevices: make([]dto.AuthorizedDeviceItem, 0),
|
||||
}
|
||||
|
||||
devicesToAllocate := make([]*model.Device, 0)
|
||||
devicesToAllocate := selectDevicesForAllocate(
|
||||
deviceNos, deviceMap, activeAuthEnterpriseMap, enterpriseID, userType, currentShopID, resp,
|
||||
)
|
||||
|
||||
if len(devicesToAllocate) > 0 {
|
||||
items, err := s.allocateDevices(ctx, enterprise, ownerShop, devicesToAllocate, req, currentUserID, userType, resp)
|
||||
if err != nil {
|
||||
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备失败", enterprise, ownerShop, devicesToAllocate, err)
|
||||
return nil, err
|
||||
}
|
||||
resp.AuthorizedDevices = append(resp.AuthorizedDevices, items...)
|
||||
}
|
||||
|
||||
resp.SuccessCount = len(resp.AuthorizedDevices)
|
||||
resp.FailCount = len(resp.FailedItems)
|
||||
if resp.SuccessCount == 0 {
|
||||
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备被拒绝", enterprise, ownerShop, devicePointers(deviceMap, deviceIDs), errors.New(errors.CodeInvalidStatus, "没有设备满足授权条件"))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// selectDevicesForAllocate 按既有设备状态、归属和有效授权规则筛选可授权设备。
|
||||
func selectDevicesForAllocate(
|
||||
deviceNos []string,
|
||||
deviceMap map[string]*model.Device,
|
||||
activeAuthEnterpriseMap map[uint]uint,
|
||||
enterpriseID uint,
|
||||
userType int,
|
||||
currentShopID uint,
|
||||
resp *dto.AllocateDevicesResp,
|
||||
) []*model.Device {
|
||||
devices := make([]*model.Device, 0, len(deviceNos))
|
||||
seenDeviceNos := make(map[string]struct{}, len(deviceNos))
|
||||
for _, deviceNo := range deviceNos {
|
||||
if _, exists := seenDeviceNos[deviceNo]; exists {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "请求中设备号重复",
|
||||
})
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "请求中设备号重复"})
|
||||
continue
|
||||
}
|
||||
seenDeviceNos[deviceNo] = struct{}{}
|
||||
|
||||
device, exists := deviceMap[deviceNo]
|
||||
if !exists {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "设备不存在",
|
||||
})
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "无权限操作该资源或资源不存在"})
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证设备状态(必须是"已分销"状态)
|
||||
if device.Status != 2 {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "设备状态不正确,必须是已分销状态",
|
||||
})
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "设备状态不正确,必须是已分销状态"})
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证设备所有权(除非是超级管理员或平台用户)
|
||||
if userType == constants.UserTypeAgent {
|
||||
if device.ShopID == nil || *device.ShopID != currentShopID {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "无权操作此设备",
|
||||
})
|
||||
continue
|
||||
}
|
||||
if userType == constants.UserTypeAgent && (device.ShopID == nil || *device.ShopID != currentShopID) {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "无权限操作该资源或资源不存在"})
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否已授权(同企业 / 其他企业)
|
||||
if authEnterpriseID, exists := activeAuthEnterpriseMap[device.ID]; exists {
|
||||
reason := "设备已授权给其他企业"
|
||||
if authEnterpriseID == enterpriseID {
|
||||
reason = "设备已授权给此企业"
|
||||
}
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
VirtualNo: deviceNo,
|
||||
Reason: reason,
|
||||
})
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: reason})
|
||||
continue
|
||||
}
|
||||
|
||||
devicesToAllocate = append(devicesToAllocate, device)
|
||||
devices = append(devices, device)
|
||||
}
|
||||
|
||||
// 在事务中处理授权
|
||||
if len(devicesToAllocate) > 0 {
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now()
|
||||
authorizerType := userType
|
||||
|
||||
// 1. 创建设备授权记录(逐条处理,避免并发冲突导致整批失败)
|
||||
deviceAuthIDMap := make(map[uint]uint, len(devicesToAllocate))
|
||||
successDevices := make([]*model.Device, 0, len(devicesToAllocate))
|
||||
for _, device := range devicesToAllocate {
|
||||
deviceAuth := &model.EnterpriseDeviceAuthorization{
|
||||
EnterpriseID: enterpriseID,
|
||||
DeviceID: device.ID,
|
||||
AuthorizedBy: currentUserID,
|
||||
AuthorizedAt: now,
|
||||
AuthorizerType: authorizerType,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
|
||||
if err := tx.Create(deviceAuth).Error; err != nil {
|
||||
if isUniqueConstraintViolation(err, "uq_active_device_auth") {
|
||||
reason := "设备已授权给其他企业"
|
||||
|
||||
var existingAuth model.EnterpriseDeviceAuthorization
|
||||
queryErr := tx.Select("enterprise_id").
|
||||
Where("device_id = ? AND revoked_at IS NULL", device.ID).
|
||||
First(&existingAuth).Error
|
||||
if queryErr == nil && existingAuth.EnterpriseID == enterpriseID {
|
||||
reason = "设备已授权给此企业"
|
||||
}
|
||||
if queryErr != nil && !stderrors.Is(queryErr, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrap(errors.CodeInternalError, queryErr, "查询冲突授权记录失败")
|
||||
}
|
||||
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
VirtualNo: device.VirtualNo,
|
||||
Reason: reason,
|
||||
})
|
||||
continue
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建设备授权记录失败")
|
||||
}
|
||||
|
||||
deviceAuthIDMap[device.ID] = deviceAuth.ID
|
||||
successDevices = append(successDevices, device)
|
||||
}
|
||||
|
||||
// 2. 查询所有设备绑定的卡
|
||||
deviceIDsToQuery := make([]uint, 0, len(successDevices))
|
||||
for _, device := range successDevices {
|
||||
deviceIDsToQuery = append(deviceIDsToQuery, device.ID)
|
||||
}
|
||||
|
||||
var bindings []model.DeviceSimBinding
|
||||
if len(deviceIDsToQuery) > 0 {
|
||||
if err := tx.Where("device_id IN ? AND bind_status = 1", deviceIDsToQuery).Find(&bindings).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 为每张绑定的卡创建授权记录
|
||||
if len(bindings) > 0 {
|
||||
cardAuths := make([]*model.EnterpriseCardAuthorization, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
deviceAuthID := deviceAuthIDMap[binding.DeviceID]
|
||||
cardAuths = append(cardAuths, &model.EnterpriseCardAuthorization{
|
||||
EnterpriseID: enterpriseID,
|
||||
CardID: binding.IotCardID,
|
||||
DeviceAuthID: &deviceAuthID,
|
||||
AuthorizedBy: currentUserID,
|
||||
AuthorizedAt: now,
|
||||
AuthorizerType: authorizerType,
|
||||
Remark: req.Remark,
|
||||
})
|
||||
}
|
||||
|
||||
if err := tx.Create(cardAuths).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建卡授权记录失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 统计每个设备的绑定卡数量
|
||||
deviceCardCount := make(map[uint]int)
|
||||
for _, binding := range bindings {
|
||||
deviceCardCount[binding.DeviceID]++
|
||||
}
|
||||
|
||||
// 5. 构建响应
|
||||
for _, device := range successDevices {
|
||||
resp.AuthorizedDevices = append(resp.AuthorizedDevices, dto.AuthorizedDeviceItem{
|
||||
DeviceID: device.ID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
CardCount: deviceCardCount[device.ID],
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
resp.SuccessCount = len(resp.AuthorizedDevices)
|
||||
resp.FailCount = len(resp.FailedItems)
|
||||
return resp, nil
|
||||
return devices
|
||||
}
|
||||
|
||||
// allocateDevices 在同一事务内创建设备、随设备卡授权和统一审计事实。
|
||||
func (s *Service) allocateDevices(
|
||||
ctx context.Context,
|
||||
enterprise *model.Enterprise,
|
||||
ownerShop *model.Shop,
|
||||
devices []*model.Device,
|
||||
req *dto.AllocateDevicesReq,
|
||||
operatorID uint,
|
||||
userType int,
|
||||
resp *dto.AllocateDevicesResp,
|
||||
) ([]dto.AuthorizedDeviceItem, error) {
|
||||
items := make([]dto.AuthorizedDeviceItem, 0, len(devices))
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", deviceIDs(devices)).Find(&[]model.Device{}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "锁定待授权设备失败")
|
||||
}
|
||||
now := time.Now()
|
||||
deviceAuthByDevice := make(map[uint]*model.EnterpriseDeviceAuthorization, len(devices))
|
||||
successDevices := make([]*model.Device, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
auth := &model.EnterpriseDeviceAuthorization{
|
||||
EnterpriseID: enterprise.ID, DeviceID: device.ID, AuthorizedBy: operatorID,
|
||||
AuthorizedAt: now, AuthorizerType: userType, Remark: req.Remark,
|
||||
}
|
||||
if err := tx.Transaction(func(itemTx *gorm.DB) error { return itemTx.Create(auth).Error }); err != nil {
|
||||
if !isUniqueConstraintViolation(err, "uq_active_device_auth") {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建设备授权记录失败")
|
||||
}
|
||||
reason, err := deviceAuthorizationConflictReason(tx, device.ID, enterprise.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: device.VirtualNo, Reason: reason})
|
||||
continue
|
||||
}
|
||||
deviceAuthByDevice[device.ID] = auth
|
||||
successDevices = append(successDevices, device)
|
||||
}
|
||||
if len(successDevices) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
successDeviceIDs := deviceIDs(successDevices)
|
||||
bindings, err := loadDeviceBindings(tx, successDeviceIDs, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cardAuths := make([]*model.EnterpriseCardAuthorization, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
deviceAuthID := deviceAuthByDevice[binding.DeviceID].ID
|
||||
cardAuths = append(cardAuths, &model.EnterpriseCardAuthorization{
|
||||
EnterpriseID: enterprise.ID, CardID: binding.IotCardID, DeviceAuthID: &deviceAuthID,
|
||||
AuthorizedBy: operatorID, AuthorizedAt: now, AuthorizerType: userType, Remark: req.Remark,
|
||||
})
|
||||
}
|
||||
if len(cardAuths) > 0 {
|
||||
if err := tx.Create(cardAuths).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建卡授权记录失败")
|
||||
}
|
||||
}
|
||||
cards, err := loadAuditCards(tx, cardAuths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := constants.AuditResultSuccess
|
||||
if len(resp.FailedItems) > 0 {
|
||||
result = constants.AuditResultPartial
|
||||
}
|
||||
if err := s.accessAudit.WriteAccessChange(ctx, tx, enterpriseDeviceAllocateAudit(
|
||||
enterprise, ownerShop, successDevices, deviceAuthByDevice, bindings, cards, cardAuths, operatorID, result,
|
||||
)); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入企业设备授权审计失败")
|
||||
}
|
||||
|
||||
cardCount := make(map[uint]int, len(successDevices))
|
||||
for _, binding := range bindings {
|
||||
cardCount[binding.DeviceID]++
|
||||
}
|
||||
for _, device := range successDevices {
|
||||
items = append(items, dto.AuthorizedDeviceItem{
|
||||
DeviceID: device.ID, VirtualNo: device.VirtualNo, CardCount: cardCount[device.ID],
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return items, err
|
||||
}
|
||||
|
||||
func deviceAuthorizationConflictReason(tx *gorm.DB, deviceID, enterpriseID uint) (string, error) {
|
||||
reason := "设备已授权给其他企业"
|
||||
var existing model.EnterpriseDeviceAuthorization
|
||||
err := tx.Select("enterprise_id").Where("device_id = ? AND revoked_at IS NULL", deviceID).First(&existing).Error
|
||||
if err == nil && existing.EnterpriseID == enterpriseID {
|
||||
return "设备已授权给此企业", nil
|
||||
}
|
||||
if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", errors.Wrap(errors.CodeInternalError, err, "查询冲突授权记录失败")
|
||||
}
|
||||
return reason, nil
|
||||
}
|
||||
|
||||
// enterpriseDeviceAllocateAudit 装配企业、设备、卡槽、卡及授权记录的资源关系。
|
||||
func enterpriseDeviceAllocateAudit(
|
||||
enterprise *model.Enterprise,
|
||||
ownerShop *model.Shop,
|
||||
devices []*model.Device,
|
||||
deviceAuthByDevice map[uint]*model.EnterpriseDeviceAuthorization,
|
||||
bindings []*model.DeviceSimBinding,
|
||||
cards map[uint]*model.IotCard,
|
||||
cardAuths []*model.EnterpriseCardAuthorization,
|
||||
operatorID uint,
|
||||
result string,
|
||||
) accessauditapp.ChangeAudit {
|
||||
change := accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionEnterpriseDevicesAllocated, Summary: "向企业授权设备",
|
||||
Result: result, OperatorID: operatorID, Enterprise: enterprise, Shop: ownerShop,
|
||||
BeforeData: map[string]any{"authorized_device_count": 0},
|
||||
AfterData: map[string]any{"authorized_device_count": len(devices)},
|
||||
}
|
||||
for _, device := range devices {
|
||||
auth := deviceAuthByDevice[device.ID]
|
||||
change.Devices = append(change.Devices, accessauditapp.DeviceChange{
|
||||
Device: device, BeforeData: map[string]any{"enterprise_id": nil, "authorized": false},
|
||||
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备已授权给企业",
|
||||
})
|
||||
change.DeviceAuthorizations = append(change.DeviceAuthorizations, accessauditapp.EnterpriseDeviceAuthorizationChange{
|
||||
Authorization: auth, AfterData: map[string]any{"authorized": true},
|
||||
})
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
change.DeviceBindings = append(change.DeviceBindings, accessauditapp.DeviceSimBindingChange{Binding: binding})
|
||||
}
|
||||
for _, auth := range cardAuths {
|
||||
card := cards[auth.CardID]
|
||||
change.Cards = append(change.Cards, accessauditapp.IotCardChange{
|
||||
Card: card, BeforeData: map[string]any{"enterprise_id": nil, "authorized": false},
|
||||
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "卡已随设备授权给企业",
|
||||
})
|
||||
change.CardAuthorizations = append(change.CardAuthorizations, accessauditapp.EnterpriseCardAuthorizationChange{
|
||||
Authorization: auth, AfterData: map[string]any{"authorized": true},
|
||||
})
|
||||
}
|
||||
return change
|
||||
}
|
||||
|
||||
// isUniqueConstraintViolation 判断 PostgreSQL 唯一约束冲突并可限定约束名称。
|
||||
func isUniqueConstraintViolation(err error, constraintName string) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if stderrors.As(err, &pgErr) {
|
||||
@@ -296,6 +365,9 @@ func (s *Service) resolveDeviceNosForAllocate(ctx context.Context, req *dto.Allo
|
||||
}
|
||||
nos := make([]string, 0, len(devices))
|
||||
for _, d := range devices {
|
||||
if !canManageEnterpriseDevice(ctx, d) {
|
||||
continue
|
||||
}
|
||||
nos = append(nos, d.VirtualNo)
|
||||
}
|
||||
return nos, nil
|
||||
@@ -323,6 +395,9 @@ func (s *Service) resolveDeviceNosForRecall(ctx context.Context, enterpriseID ui
|
||||
}
|
||||
nos := make([]string, 0, len(devices))
|
||||
for _, d := range devices {
|
||||
if !canManageEnterpriseDevice(ctx, d) {
|
||||
continue
|
||||
}
|
||||
nos = append(nos, d.VirtualNo)
|
||||
}
|
||||
return nos, nil
|
||||
@@ -334,12 +409,27 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
|
||||
if currentUserID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
if err := validateRecallDevicesRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 验证企业存在
|
||||
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "企业设备授权审计接缝未配置")
|
||||
}
|
||||
if err := validateEnterpriseDeviceActor(ctx); err != nil {
|
||||
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, err)
|
||||
return nil, err
|
||||
}
|
||||
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
|
||||
permissionErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, permissionErr)
|
||||
return nil, permissionErr
|
||||
}
|
||||
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||||
}
|
||||
ownerShop := loadEnterpriseDeviceOwnerShop(ctx, s.db, enterprise.OwnerShopID)
|
||||
|
||||
// 根据选取模式解析候选设备号列表
|
||||
deviceNos, err := s.resolveDeviceNosForRecall(ctx, enterpriseID, req)
|
||||
@@ -349,7 +439,7 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
|
||||
|
||||
// 查询设备
|
||||
var devices []model.Device
|
||||
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
|
||||
if err := enterpriseDeviceQuery(ctx, s.db).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
|
||||
}
|
||||
|
||||
@@ -370,66 +460,386 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
|
||||
FailedItems: make([]dto.FailedDeviceItem, 0),
|
||||
}
|
||||
|
||||
deviceAuthsToRevoke := make([]uint, 0)
|
||||
for _, deviceNo := range deviceNos {
|
||||
device, exists := deviceMap[deviceNo]
|
||||
if !exists {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "设备不存在",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if !existingAuths[device.ID] {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "设备未授权给此企业",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取授权记录ID
|
||||
auth, err := s.enterpriseDeviceAuthStore.GetByDeviceID(ctx, device.ID)
|
||||
if err != nil || auth.EnterpriseID != enterpriseID {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
VirtualNo: deviceNo,
|
||||
Reason: "授权记录不存在",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
deviceAuthsToRevoke = append(deviceAuthsToRevoke, auth.ID)
|
||||
}
|
||||
|
||||
// 在事务中处理撤销
|
||||
if len(deviceAuthsToRevoke) > 0 {
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 1. 撤销设备授权
|
||||
if err := s.enterpriseDeviceAuthStore.RevokeByIDs(ctx, deviceAuthsToRevoke, currentUserID); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "撤销设备授权失败")
|
||||
}
|
||||
|
||||
// 2. 级联撤销卡授权
|
||||
for _, authID := range deviceAuthsToRevoke {
|
||||
if err := s.enterpriseCardAuthStore.RevokeByDeviceAuthID(ctx, authID, currentUserID); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "撤销卡授权失败")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
deviceIDsToRecall := selectDeviceIDsForRecall(deviceNos, deviceMap, existingAuths, resp)
|
||||
|
||||
if len(deviceIDsToRecall) > 0 {
|
||||
recalledIDs, err := s.recallDevices(ctx, enterprise, ownerShop, deviceIDsToRecall, deviceMap, currentUserID, len(resp.FailedItems) > 0)
|
||||
if err != nil {
|
||||
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权失败", enterprise, ownerShop, devicePointers(deviceMap, deviceIDsToRecall), err)
|
||||
return nil, err
|
||||
}
|
||||
recalled := make(map[uint]struct{}, len(recalledIDs))
|
||||
for _, deviceID := range recalledIDs {
|
||||
recalled[deviceID] = struct{}{}
|
||||
}
|
||||
devicesByID := devicesByID(deviceMap)
|
||||
for _, deviceID := range deviceIDsToRecall {
|
||||
if _, ok := recalled[deviceID]; ok {
|
||||
continue
|
||||
}
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
|
||||
VirtualNo: devicesByID[deviceID].VirtualNo,
|
||||
Reason: "设备未授权给此企业",
|
||||
})
|
||||
}
|
||||
resp.SuccessCount = len(recalledIDs)
|
||||
}
|
||||
|
||||
resp.SuccessCount = len(deviceAuthsToRevoke)
|
||||
resp.FailCount = len(resp.FailedItems)
|
||||
if resp.SuccessCount == 0 {
|
||||
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权被拒绝", enterprise, ownerShop, devicePointers(deviceMap, deviceIDs), errors.New(errors.CodeInvalidStatus, "没有设备满足回收条件"))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// selectDeviceIDsForRecall 按当前有效授权筛选回收目标,并保持越权与不存在同错。
|
||||
func selectDeviceIDsForRecall(
|
||||
deviceNos []string,
|
||||
deviceMap map[string]*model.Device,
|
||||
existingAuths map[uint]bool,
|
||||
resp *dto.RecallDevicesResp,
|
||||
) []uint {
|
||||
deviceIDs := make([]uint, 0, len(deviceNos))
|
||||
seenDeviceNos := make(map[string]struct{}, len(deviceNos))
|
||||
for _, deviceNo := range deviceNos {
|
||||
if _, seen := seenDeviceNos[deviceNo]; seen {
|
||||
continue
|
||||
}
|
||||
seenDeviceNos[deviceNo] = struct{}{}
|
||||
device, exists := deviceMap[deviceNo]
|
||||
if !exists {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "无权限操作该资源或资源不存在"})
|
||||
continue
|
||||
}
|
||||
if !existingAuths[device.ID] {
|
||||
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "设备未授权给此企业"})
|
||||
continue
|
||||
}
|
||||
deviceIDs = append(deviceIDs, device.ID)
|
||||
}
|
||||
return deviceIDs
|
||||
}
|
||||
|
||||
// recallDevices 在锁定有效授权后撤销实际命中项,并返回真实回收设备 ID。
|
||||
func (s *Service) recallDevices(
|
||||
ctx context.Context,
|
||||
enterprise *model.Enterprise,
|
||||
ownerShop *model.Shop,
|
||||
requestedDeviceIDs []uint,
|
||||
deviceMap map[string]*model.Device,
|
||||
operatorID uint,
|
||||
partial bool,
|
||||
) ([]uint, error) {
|
||||
recalledDeviceIDs := make([]uint, 0, len(requestedDeviceIDs))
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var deviceAuths []*model.EnterpriseDeviceAuthorization
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("enterprise_id = ? AND device_id IN ? AND revoked_at IS NULL", enterprise.ID, requestedDeviceIDs).
|
||||
Find(&deviceAuths).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询有效设备授权失败")
|
||||
}
|
||||
if len(deviceAuths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
authIDs := make([]uint, 0, len(deviceAuths))
|
||||
actualDeviceIDs := make([]uint, 0, len(deviceAuths))
|
||||
for _, auth := range deviceAuths {
|
||||
authIDs = append(authIDs, auth.ID)
|
||||
actualDeviceIDs = append(actualDeviceIDs, auth.DeviceID)
|
||||
}
|
||||
bindings, err := loadDeviceBindings(tx, actualDeviceIDs, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cardAuths []*model.EnterpriseCardAuthorization
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("device_auth_id IN ? AND revoked_at IS NULL", authIDs).
|
||||
Find(&cardAuths).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询有效卡授权失败")
|
||||
}
|
||||
cards, err := loadAuditCards(tx, cardAuths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := tx.Model(&model.EnterpriseDeviceAuthorization{}).
|
||||
Where("id IN ? AND revoked_at IS NULL", authIDs).
|
||||
Updates(map[string]any{"revoked_by": operatorID, "revoked_at": now}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "撤销设备授权失败")
|
||||
}
|
||||
if len(cardAuths) > 0 {
|
||||
cardAuthIDs := make([]uint, 0, len(cardAuths))
|
||||
for _, auth := range cardAuths {
|
||||
cardAuthIDs = append(cardAuthIDs, auth.ID)
|
||||
}
|
||||
if err := tx.Model(&model.EnterpriseCardAuthorization{}).
|
||||
Where("id IN ? AND revoked_at IS NULL", cardAuthIDs).
|
||||
Updates(map[string]any{"revoked_by": operatorID, "revoked_at": now}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "撤销卡授权失败")
|
||||
}
|
||||
}
|
||||
|
||||
devicesByID := devicesByID(deviceMap)
|
||||
result := constants.AuditResultSuccess
|
||||
if partial || len(deviceAuths) < len(requestedDeviceIDs) {
|
||||
result = constants.AuditResultPartial
|
||||
}
|
||||
change := enterpriseDeviceRecallAudit(
|
||||
enterprise, ownerShop, devicesByID, deviceAuths, bindings, cards, cardAuths, operatorID, now, result,
|
||||
)
|
||||
if err := s.accessAudit.WriteAccessChange(ctx, tx, change); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入企业设备回收审计失败")
|
||||
}
|
||||
recalledDeviceIDs = actualDeviceIDs
|
||||
return nil
|
||||
})
|
||||
return recalledDeviceIDs, err
|
||||
}
|
||||
|
||||
// enterpriseDeviceRecallAudit 装配回收操作涉及的设备、卡槽、卡和授权记录变化。
|
||||
func enterpriseDeviceRecallAudit(
|
||||
enterprise *model.Enterprise,
|
||||
ownerShop *model.Shop,
|
||||
devices map[uint]*model.Device,
|
||||
deviceAuths []*model.EnterpriseDeviceAuthorization,
|
||||
bindings []*model.DeviceSimBinding,
|
||||
cards map[uint]*model.IotCard,
|
||||
cardAuths []*model.EnterpriseCardAuthorization,
|
||||
operatorID uint,
|
||||
now time.Time,
|
||||
result string,
|
||||
) accessauditapp.ChangeAudit {
|
||||
change := accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionEnterpriseDevicesRecalled, Summary: "回收企业设备授权",
|
||||
Result: result, OperatorID: operatorID, Enterprise: enterprise, Shop: ownerShop,
|
||||
BeforeData: map[string]any{"authorized_device_count": len(deviceAuths)},
|
||||
AfterData: map[string]any{"authorized_device_count": 0},
|
||||
}
|
||||
for _, auth := range deviceAuths {
|
||||
device := devices[auth.DeviceID]
|
||||
change.Devices = append(change.Devices, accessauditapp.DeviceChange{
|
||||
Device: device,
|
||||
BeforeData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
|
||||
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": false},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备授权已回收",
|
||||
})
|
||||
beforeRevokedBy, beforeRevokedAt := auth.RevokedBy, auth.RevokedAt
|
||||
auth.RevokedBy, auth.RevokedAt = &operatorID, &now
|
||||
change.DeviceAuthorizations = append(change.DeviceAuthorizations, accessauditapp.EnterpriseDeviceAuthorizationChange{
|
||||
Authorization: auth,
|
||||
BeforeData: map[string]any{"revoked_by": beforeRevokedBy, "revoked_at": beforeRevokedAt},
|
||||
AfterData: map[string]any{"revoked_by": operatorID, "revoked_at": now},
|
||||
})
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
change.DeviceBindings = append(change.DeviceBindings, accessauditapp.DeviceSimBindingChange{Binding: binding})
|
||||
}
|
||||
for _, auth := range cardAuths {
|
||||
card := cards[auth.CardID]
|
||||
change.Cards = append(change.Cards, accessauditapp.IotCardChange{
|
||||
Card: card,
|
||||
BeforeData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
|
||||
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": false},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "卡授权已随设备回收",
|
||||
})
|
||||
beforeRevokedBy, beforeRevokedAt := auth.RevokedBy, auth.RevokedAt
|
||||
auth.RevokedBy, auth.RevokedAt = &operatorID, &now
|
||||
change.CardAuthorizations = append(change.CardAuthorizations, accessauditapp.EnterpriseCardAuthorizationChange{
|
||||
Authorization: auth,
|
||||
BeforeData: map[string]any{"revoked_by": beforeRevokedBy, "revoked_at": beforeRevokedAt},
|
||||
AfterData: map[string]any{"revoked_by": operatorID, "revoked_at": now},
|
||||
})
|
||||
}
|
||||
return change
|
||||
}
|
||||
|
||||
func validateEnterpriseDeviceActor(ctx context.Context) error {
|
||||
switch middleware.GetUserTypeFromContext(ctx) {
|
||||
case constants.UserTypeSuperAdmin, constants.UserTypePlatform, constants.UserTypeAgent:
|
||||
return nil
|
||||
default:
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
}
|
||||
|
||||
// validateAllocateDevicesRequest 校验 Service 边界的授权选取条件,防止空筛选扩散为全量操作。
|
||||
func validateAllocateDevicesRequest(req *dto.AllocateDevicesReq) error {
|
||||
if req == nil || utf8.RuneCountInString(req.VirtualNo) > 100 || utf8.RuneCountInString(req.BatchNo) > 100 || utf8.RuneCountInString(req.Remark) > 500 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if req.SelectionType == "filter" {
|
||||
if req.VirtualNo == "" && req.BatchNo == "" && req.ShopID == nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if req.ShopID != nil && *req.ShopID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if req.SelectionType != "list" || len(req.DeviceNos) == 0 || len(req.DeviceNos) > 100 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
for _, deviceNo := range req.DeviceNos {
|
||||
if deviceNo == "" {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRecallDevicesRequest 校验 Service 边界的回收选取条件,防止空筛选扩散为全量操作。
|
||||
func validateRecallDevicesRequest(req *dto.RecallDevicesReq) error {
|
||||
if req == nil || utf8.RuneCountInString(req.VirtualNo) > 100 || utf8.RuneCountInString(req.BatchNo) > 100 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if req.SelectionType == "filter" {
|
||||
if req.VirtualNo == "" && req.BatchNo == "" {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if req.SelectionType != "list" || len(req.DeviceNos) == 0 || len(req.DeviceNos) > 100 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
for _, deviceNo := range req.DeviceNos {
|
||||
if deviceNo == "" {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func enterpriseDeviceQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
|
||||
query := db.WithContext(ctx).Model(&model.Device{})
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
|
||||
return query
|
||||
}
|
||||
shopID := middleware.GetShopIDFromContext(ctx)
|
||||
if shopID == 0 {
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
return query.Where("shop_id = ?", shopID)
|
||||
}
|
||||
|
||||
func canManageEnterpriseDevice(ctx context.Context, device *model.Device) bool {
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
|
||||
return true
|
||||
}
|
||||
shopID := middleware.GetShopIDFromContext(ctx)
|
||||
return shopID > 0 && device.ShopID != nil && *device.ShopID == shopID
|
||||
}
|
||||
|
||||
func loadEnterpriseDeviceOwnerShop(ctx context.Context, db *gorm.DB, ownerShopID *uint) *model.Shop {
|
||||
if ownerShopID == nil {
|
||||
return nil
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := db.WithContext(ctx).Unscoped().First(&shop, *ownerShopID).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &shop
|
||||
}
|
||||
|
||||
func loadDeviceBindings(tx *gorm.DB, deviceIDs []uint, lock bool) ([]*model.DeviceSimBinding, error) {
|
||||
bindings := make([]*model.DeviceSimBinding, 0)
|
||||
if len(deviceIDs) == 0 {
|
||||
return bindings, nil
|
||||
}
|
||||
query := tx.Where("device_id IN ? AND bind_status = 1", deviceIDs)
|
||||
if lock {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
if err := query.Find(&bindings).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
|
||||
}
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
// loadAuditCards 使用非作用域查询保留软删除卡的稳定审计身份快照。
|
||||
func loadAuditCards(tx *gorm.DB, auths []*model.EnterpriseCardAuthorization) (map[uint]*model.IotCard, error) {
|
||||
cardIDs := make([]uint, 0, len(auths))
|
||||
for _, auth := range auths {
|
||||
cardIDs = append(cardIDs, auth.CardID)
|
||||
}
|
||||
cards := make(map[uint]*model.IotCard, len(cardIDs))
|
||||
if len(cardIDs) == 0 {
|
||||
return cards, nil
|
||||
}
|
||||
var values []*model.IotCard
|
||||
if err := tx.Unscoped().Where("id IN ?", cardIDs).Find(&values).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定卡审计快照失败")
|
||||
}
|
||||
for _, card := range values {
|
||||
cards[card.ID] = card
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func deviceIDs(devices []*model.Device) []uint {
|
||||
ids := make([]uint, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
ids = append(ids, device.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func devicesByID(deviceMap map[string]*model.Device) map[uint]*model.Device {
|
||||
result := make(map[uint]*model.Device, len(deviceMap))
|
||||
for _, device := range deviceMap {
|
||||
result[device.ID] = device
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func devicePointers(deviceMap map[string]*model.Device, ids []uint) []*model.Device {
|
||||
wanted := make(map[uint]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
devices := make([]*model.Device, 0, len(ids))
|
||||
for _, device := range deviceMap {
|
||||
if _, ok := wanted[device.ID]; ok {
|
||||
devices = append(devices, device)
|
||||
}
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
// recordDeviceFailure 在业务事务结束后使用统一 Writer 记录失败或拒绝事实。
|
||||
func (s *Service) recordDeviceFailure(
|
||||
ctx context.Context,
|
||||
actionCode, summary string,
|
||||
enterprise *model.Enterprise,
|
||||
ownerShop *model.Shop,
|
||||
devices []*model.Device,
|
||||
originalErr error,
|
||||
) {
|
||||
changes := make([]accessauditapp.DeviceChange, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
changes = append(changes, accessauditapp.DeviceChange{Device: device})
|
||||
}
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
|
||||
ActionCode: actionCode, Summary: summary, Result: enterpriseDeviceFailureResult(originalErr),
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), Enterprise: enterprise, Shop: ownerShop,
|
||||
Devices: changes, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}, originalErr)
|
||||
}
|
||||
|
||||
func enterpriseDeviceFailureResult(err error) string {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
switch appErr.Code {
|
||||
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeEnterpriseNotFound:
|
||||
return constants.AuditResultDenied
|
||||
case errors.CodeInvalidStatus:
|
||||
return constants.AuditResultDenied
|
||||
}
|
||||
}
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
|
||||
// ListDevices 查询企业授权设备列表(后台管理)
|
||||
func (s *Service) ListDevices(ctx context.Context, enterpriseID uint, req *dto.EnterpriseDeviceListReq) (*dto.EnterpriseDeviceListResp, error) {
|
||||
// 验证企业存在
|
||||
|
||||
@@ -52,6 +52,7 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d
|
||||
ResourceKey: &card.ICCID,
|
||||
RequestID: requestID,
|
||||
CorrelationID: requestID,
|
||||
TriggerSeries: requestID,
|
||||
RequestSummary: map[string]any{
|
||||
"iot_card_id": card.ID,
|
||||
"iccid": card.ICCID,
|
||||
@@ -70,6 +71,13 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d
|
||||
CardNo: card.ICCID,
|
||||
Code: strconv.Itoa(*code),
|
||||
})
|
||||
if gatewayErr != nil && s.logger != nil {
|
||||
s.logger.Warn("Gateway 卡限速请求失败",
|
||||
zap.Uint("iot_card_id", card.ID),
|
||||
zap.String("integration_id", attempt.IntegrationID),
|
||||
zap.Error(gatewayErr),
|
||||
)
|
||||
}
|
||||
completion := speedTierCompletion(gatewayErr, time.Since(startedAt))
|
||||
if _, completeErr := s.speedTierIntegration.Complete(ctx, attempt.IntegrationID, completion); completeErr != nil {
|
||||
if s.logger != nil {
|
||||
@@ -120,7 +128,7 @@ func speedTierCompletion(err error, duration time.Duration) integrationlog.Compl
|
||||
}
|
||||
completion.Result = constants.IntegrationResultFailed
|
||||
completion.StateChanged = false
|
||||
completion.ProviderMessage = err.Error()
|
||||
completion.SafeProviderMessage = "Gateway 卡限速请求失败"
|
||||
completion.ResponseSummary = map[string]any{"result": "failed"}
|
||||
if isGatewayTimeout(err) {
|
||||
completion.Result = constants.IntegrationResultUnknown
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
@@ -28,6 +29,8 @@ type AccountServiceInterface interface {
|
||||
|
||||
// Service 权限业务服务
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
accessAudit accessauditapp.Writer
|
||||
permissionStore *postgres.PermissionStore
|
||||
accountRoleStore *postgres.AccountRoleStore
|
||||
rolePermStore *postgres.RolePermissionStore
|
||||
@@ -35,6 +38,12 @@ type Service struct {
|
||||
redisClient *redis.Client
|
||||
}
|
||||
|
||||
// SetAccessAudit 注入权限定义变更的事务审计接缝。
|
||||
func (s *Service) SetAccessAudit(db *gorm.DB, writer accessauditapp.Writer) {
|
||||
s.db = db
|
||||
s.accessAudit = writer
|
||||
}
|
||||
|
||||
// New 创建权限服务
|
||||
func New(
|
||||
permissionStore *postgres.PermissionStore,
|
||||
@@ -60,26 +69,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePermissionRequest)
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
// 验证权限编码格式
|
||||
if !permCodeRegex.MatchString(req.PermCode) {
|
||||
return nil, errors.New(errors.CodeInvalidPermCode, "权限编码格式不正确(应为 module:action 格式)")
|
||||
}
|
||||
|
||||
// 检查权限编码唯一性
|
||||
existing, err := s.permissionStore.GetByCode(ctx, req.PermCode)
|
||||
if err == nil && existing != nil {
|
||||
return nil, errors.New(errors.CodePermCodeExists, "权限编码已存在")
|
||||
}
|
||||
|
||||
// 验证 parent_id 存在(如果提供)
|
||||
if req.ParentID != nil {
|
||||
parent, err := s.permissionStore.GetByID(ctx, *req.ParentID)
|
||||
if err != nil || parent == nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "上级权限不存在")
|
||||
}
|
||||
}
|
||||
|
||||
// 创建权限
|
||||
permission := &model.Permission{
|
||||
PermName: req.PermName,
|
||||
PermCode: req.PermCode,
|
||||
@@ -89,14 +78,59 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePermissionRequest)
|
||||
ParentID: req.ParentID,
|
||||
Sort: req.Sort,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: currentUserID,
|
||||
Updater: currentUserID,
|
||||
},
|
||||
}
|
||||
|
||||
// 如果未指定 platform,默认为 all
|
||||
if permission.Platform == "" {
|
||||
permission.Platform = constants.PlatformAll
|
||||
}
|
||||
|
||||
if err := s.permissionStore.Create(ctx, permission); err != nil {
|
||||
// 验证权限编码格式
|
||||
if !permCodeRegex.MatchString(req.PermCode) {
|
||||
appErr := errors.New(errors.CodeInvalidPermCode, "权限编码格式不正确(应为 module:action 格式)")
|
||||
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "拒绝创建非法权限编码", constants.AuditResultDenied, permission, nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
// 检查权限编码唯一性
|
||||
existing, err := s.permissionStore.GetByCode(ctx, req.PermCode)
|
||||
if err == nil && existing != nil {
|
||||
appErr := errors.New(errors.CodePermCodeExists, "权限编码已存在")
|
||||
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "拒绝创建重复权限编码", constants.AuditResultDenied, permission, nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "创建权限失败", constants.AuditResultFailed, permission, nil, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "检查权限编码失败")
|
||||
}
|
||||
|
||||
// 验证 parent_id 存在(如果提供)
|
||||
if req.ParentID != nil {
|
||||
parent, err := s.permissionStore.GetByID(ctx, *req.ParentID)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "创建权限失败", constants.AuditResultFailed, permission, nil, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "检查上级权限失败")
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound || parent == nil {
|
||||
appErr := errors.New(errors.CodeNotFound, "上级权限不存在")
|
||||
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "拒绝创建上级不存在的权限", constants.AuditResultDenied, permission, nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
|
||||
if err := postgres.NewPermissionStore(tx).Create(ctx, permission); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionPermissionCreated, Summary: "创建权限", Result: constants.AuditResultSuccess,
|
||||
OperatorID: currentUserID, Permissions: permissionChanges(permission, nil, permissionAuditData(permission)),
|
||||
})
|
||||
}); err != nil {
|
||||
permission.ID = 0
|
||||
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "创建权限失败", constants.AuditResultFailed, permission, nil, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建权限失败")
|
||||
}
|
||||
|
||||
@@ -131,6 +165,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取权限失败")
|
||||
}
|
||||
beforeData := permissionAuditData(permission)
|
||||
|
||||
// 更新字段
|
||||
if req.PermName != nil {
|
||||
@@ -139,12 +174,20 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
|
||||
if req.PermCode != nil {
|
||||
// 验证权限编码格式
|
||||
if !permCodeRegex.MatchString(*req.PermCode) {
|
||||
return nil, errors.New(errors.CodeInvalidPermCode, "权限编码格式不正确(应为 module:action 格式)")
|
||||
appErr := errors.New(errors.CodeInvalidPermCode, "权限编码格式不正确(应为 module:action 格式)")
|
||||
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "拒绝更新非法权限编码", constants.AuditResultDenied, permission, beforeData, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
// 检查新权限编码唯一性
|
||||
existing, err := s.permissionStore.GetByCode(ctx, *req.PermCode)
|
||||
if err == nil && existing != nil && existing.ID != id {
|
||||
return nil, errors.New(errors.CodePermCodeExists, "权限编码已存在")
|
||||
appErr := errors.New(errors.CodePermCodeExists, "权限编码已存在")
|
||||
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "拒绝更新重复权限编码", constants.AuditResultDenied, permission, beforeData, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "更新权限失败", constants.AuditResultFailed, permission, beforeData, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "检查权限编码失败")
|
||||
}
|
||||
permission.PermCode = *req.PermCode
|
||||
}
|
||||
@@ -157,8 +200,14 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
|
||||
if req.ParentID != nil {
|
||||
// 验证 parent_id 存在
|
||||
parent, err := s.permissionStore.GetByID(ctx, *req.ParentID)
|
||||
if err != nil || parent == nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "上级权限不存在")
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "更新权限失败", constants.AuditResultFailed, permission, beforeData, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "检查上级权限失败")
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound || parent == nil {
|
||||
appErr := errors.New(errors.CodeNotFound, "上级权限不存在")
|
||||
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "拒绝更新不存在的上级权限", constants.AuditResultDenied, permission, beforeData, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
permission.ParentID = req.ParentID
|
||||
}
|
||||
@@ -171,9 +220,25 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
|
||||
|
||||
permission.Updater = currentUserID
|
||||
|
||||
if err := s.permissionStore.Update(ctx, permission); err != nil {
|
||||
var accountIDs []uint
|
||||
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
|
||||
if err := postgres.NewPermissionStore(tx).Update(ctx, permission); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
accountIDs, err = permissionCacheAccountIDs(ctx, tx, permission.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionPermissionUpdated, Summary: "更新权限", Result: constants.AuditResultSuccess,
|
||||
OperatorID: currentUserID, Permissions: permissionChanges(permission, beforeData, permissionAuditData(permission)),
|
||||
})
|
||||
}); err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "更新权限失败", constants.AuditResultFailed, permission, beforeData, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新权限失败")
|
||||
}
|
||||
s.clearPermissionCaches(ctx, accountIDs)
|
||||
|
||||
return permission, nil
|
||||
}
|
||||
@@ -181,7 +246,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
|
||||
// Delete 软删除权限
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
// 检查权限存在
|
||||
_, err := s.permissionStore.GetByID(ctx, id)
|
||||
permission, err := s.permissionStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodePermissionNotFound, "权限不存在")
|
||||
@@ -189,9 +254,27 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取权限失败")
|
||||
}
|
||||
|
||||
if err := s.permissionStore.Delete(ctx, id); err != nil {
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
beforeData := permissionAuditData(permission)
|
||||
var accountIDs []uint
|
||||
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
|
||||
if err := postgres.NewPermissionStore(tx).Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
accountIDs, err = permissionCacheAccountIDs(ctx, tx, permission.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionPermissionDeleted, Summary: "删除权限", Result: constants.AuditResultSuccess,
|
||||
OperatorID: operatorID, Permissions: permissionChanges(permission, beforeData, map[string]any{"deleted": true}),
|
||||
})
|
||||
}); err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionPermissionDeleted, "删除权限失败", constants.AuditResultFailed, permission, beforeData, err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除权限失败")
|
||||
}
|
||||
s.clearPermissionCaches(ctx, accountIDs)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -351,3 +434,70 @@ func (s *Service) matchPermission(permissions []permissionCacheItem, permCode st
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) runAccessTransaction(ctx context.Context, fn func(tx *gorm.DB) error) error {
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "权限审计接缝未配置")
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(fn)
|
||||
}
|
||||
|
||||
func (s *Service) recordFailure(
|
||||
ctx context.Context,
|
||||
actionCode, summary, result string,
|
||||
permission *model.Permission,
|
||||
beforeData map[string]any,
|
||||
originalErr error,
|
||||
) {
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
|
||||
ActionCode: actionCode, Summary: summary, Result: result,
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||
Permissions: permissionChanges(permission, beforeData, nil),
|
||||
}, originalErr)
|
||||
}
|
||||
|
||||
func permissionChanges(permission *model.Permission, beforeData, afterData map[string]any) []accessauditapp.PermissionChange {
|
||||
if permission == nil {
|
||||
return nil
|
||||
}
|
||||
return []accessauditapp.PermissionChange{{Permission: permission, BeforeData: beforeData, AfterData: afterData}}
|
||||
}
|
||||
|
||||
func permissionAuditData(permission *model.Permission) map[string]any {
|
||||
if permission == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"perm_name": permission.PermName, "perm_code": permission.PermCode, "perm_type": permission.PermType,
|
||||
"platform": permission.Platform, "available_for_role_types": permission.AvailableForRoleTypes,
|
||||
"url": permission.URL, "parent_id": permission.ParentID, "sort": permission.Sort, "status": permission.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func permissionCacheAccountIDs(ctx context.Context, tx *gorm.DB, permissionID uint) ([]uint, error) {
|
||||
var roleIDs []uint
|
||||
if err := tx.WithContext(ctx).Model(&model.RolePermission{}).
|
||||
Where("perm_id = ?", permissionID).Pluck("role_id", &roleIDs).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询权限关联角色失败")
|
||||
}
|
||||
if len(roleIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var accountIDs []uint
|
||||
if err := tx.WithContext(ctx).Model(&model.AccountRole{}).
|
||||
Where("role_id IN ?", roleIDs).Distinct().Pluck("account_id", &accountIDs).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询权限关联账号失败")
|
||||
}
|
||||
return accountIDs, nil
|
||||
}
|
||||
|
||||
func (s *Service) clearPermissionCaches(ctx context.Context, accountIDs []uint) {
|
||||
if len(accountIDs) == 0 || s.redisClient == nil {
|
||||
return
|
||||
}
|
||||
pipe := s.redisClient.Pipeline()
|
||||
for _, accountID := range accountIDs {
|
||||
pipe.Del(ctx, constants.RedisUserPermissionsKey(accountID))
|
||||
}
|
||||
_, _ = pipe.Exec(ctx)
|
||||
}
|
||||
|
||||
@@ -3,59 +3,88 @@ package personal_customer
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Service 个人客户服务
|
||||
type Service struct {
|
||||
store *postgres.PersonalCustomerStore
|
||||
phoneStore *postgres.PersonalCustomerPhoneStore
|
||||
logger *zap.Logger
|
||||
db *gorm.DB
|
||||
store *postgres.PersonalCustomerStore
|
||||
phoneStore *postgres.PersonalCustomerPhoneStore
|
||||
logger *zap.Logger
|
||||
accessAudit accessauditapp.Writer
|
||||
}
|
||||
|
||||
// NewService 创建个人客户服务实例
|
||||
func NewService(
|
||||
db *gorm.DB,
|
||||
store *postgres.PersonalCustomerStore,
|
||||
phoneStore *postgres.PersonalCustomerPhoneStore,
|
||||
logger *zap.Logger,
|
||||
accessAudit accessauditapp.Writer,
|
||||
) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
phoneStore: phoneStore,
|
||||
logger: logger,
|
||||
db: db,
|
||||
store: store,
|
||||
phoneStore: phoneStore,
|
||||
logger: logger,
|
||||
accessAudit: accessAudit,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateProfile 更新个人资料
|
||||
func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname, avatarURL string) error {
|
||||
customer, err := s.store.GetByID(ctx, customerID)
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
|
||||
}
|
||||
|
||||
customer := &model.PersonalCustomer{Model: gorm.Model{ID: customerID}}
|
||||
failureCustomer := customer
|
||||
var beforeData map[string]any
|
||||
loaded := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(customer, customerID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
|
||||
}
|
||||
loaded = true
|
||||
beforeData = personalProfileAuditData(customer)
|
||||
current := *customer
|
||||
failureCustomer = ¤t
|
||||
if nickname != "" {
|
||||
customer.Nickname = nickname
|
||||
}
|
||||
if avatarURL != "" {
|
||||
customer.AvatarURL = avatarURL
|
||||
}
|
||||
if err := tx.Save(customer).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新个人资料失败")
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, personalProfileAudit(customer, beforeData, constants.AuditResultSuccess))
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("查询个人客户失败",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
|
||||
}
|
||||
|
||||
// 更新资料
|
||||
if nickname != "" {
|
||||
customer.Nickname = nickname
|
||||
}
|
||||
if avatarURL != "" {
|
||||
customer.AvatarURL = avatarURL
|
||||
}
|
||||
|
||||
if err := s.store.Update(ctx, customer); err != nil {
|
||||
if !loaded {
|
||||
s.logger.Error("查询个人客户失败", zap.Uint("customer_id", customerID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
s.logger.Error("更新个人资料失败",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新个人资料失败")
|
||||
failure := personalProfileAudit(failureCustomer, beforeData, personalAuditFailureResult(err))
|
||||
failure.Summary = "更新个人资料失败"
|
||||
failure.SubjectSummary = "个人资料更新失败"
|
||||
failure.SubjectData = nil
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, failure, err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("更新个人资料成功",
|
||||
@@ -65,6 +94,32 @@ func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname,
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalProfileAudit(customer *model.PersonalCustomer, beforeData map[string]any, result string) accessauditapp.ChangeAudit {
|
||||
return accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionPersonalCustomerProfileUpdated, Summary: "更新个人资料", Result: result,
|
||||
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
|
||||
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
|
||||
PersonalCustomer: customer, BeforeData: beforeData, AfterData: personalProfileAuditData(customer),
|
||||
SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: "个人资料已更新",
|
||||
SubjectData: map[string]any{"nickname": customer.Nickname, "avatar_url": customer.AvatarURL},
|
||||
}
|
||||
}
|
||||
|
||||
func personalProfileAuditData(customer *model.PersonalCustomer) map[string]any {
|
||||
return map[string]any{"nickname": customer.Nickname, "avatar_url": customer.AvatarURL}
|
||||
}
|
||||
|
||||
func personalAuditFailureResult(err error) string {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
switch appErr.Code {
|
||||
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeCustomerNotFound:
|
||||
return constants.AuditResultDenied
|
||||
}
|
||||
}
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
|
||||
// GetProfileWithPhone 获取个人资料(包含主手机号)
|
||||
func (s *Service) GetProfileWithPhone(ctx context.Context, customerID uint) (*model.PersonalCustomer, string, error) {
|
||||
// 获取客户信息
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
@@ -15,11 +16,15 @@ import (
|
||||
"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/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Service 角色业务服务
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
redisClient *redis.Client
|
||||
accessAudit accessauditapp.Writer
|
||||
roleStore *postgres.RoleStore
|
||||
permissionStore *postgres.PermissionStore
|
||||
rolePermissionStore *postgres.RolePermissionStore
|
||||
@@ -27,6 +32,13 @@ type Service struct {
|
||||
shopRoleStore *postgres.ShopRoleStore
|
||||
}
|
||||
|
||||
// SetAccessAudit 注入角色与权限配置的事务审计接缝。
|
||||
func (s *Service) SetAccessAudit(db *gorm.DB, redisClient *redis.Client, writer accessauditapp.Writer) {
|
||||
s.db = db
|
||||
s.redisClient = redisClient
|
||||
s.accessAudit = writer
|
||||
}
|
||||
|
||||
// New 创建角色服务
|
||||
func New(roleStore *postgres.RoleStore, permissionStore *postgres.PermissionStore, rolePermissionStore *postgres.RolePermissionStore, accountRoleStore *postgres.AccountRoleStore, shopRoleStore *postgres.ShopRoleStore) *Service {
|
||||
return &Service{
|
||||
@@ -46,24 +58,40 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRoleRequest) (*dto.
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
// 检查角色名是否已存在
|
||||
exists, err := s.roleStore.ExistsByName(ctx, req.RoleName, 0)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "检查角色名失败")
|
||||
}
|
||||
if exists {
|
||||
return nil, errors.New(errors.CodeRoleNameExists)
|
||||
}
|
||||
|
||||
// 创建角色
|
||||
role := &model.Role{
|
||||
RoleName: req.RoleName,
|
||||
RoleDesc: req.RoleDesc,
|
||||
RoleType: req.RoleType,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: currentUserID,
|
||||
Updater: currentUserID,
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.roleStore.Create(ctx, role); err != nil {
|
||||
// 检查角色名是否已存在
|
||||
exists, err := s.roleStore.ExistsByName(ctx, req.RoleName, 0)
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRoleCreated, "创建角色失败", constants.AuditResultFailed, role, nil, nil, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "检查角色名失败")
|
||||
}
|
||||
if exists {
|
||||
appErr := errors.New(errors.CodeRoleNameExists)
|
||||
s.recordFailure(ctx, constants.AuditActionRoleCreated, "拒绝创建重复角色", constants.AuditResultDenied, role, nil, nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
|
||||
if err := postgres.NewRoleStore(tx).Create(ctx, role); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionRoleCreated, Summary: "创建角色", Result: constants.AuditResultSuccess,
|
||||
OperatorID: currentUserID, Role: role, AfterData: roleAuditData(role),
|
||||
})
|
||||
}); err != nil {
|
||||
role.ID = 0
|
||||
s.recordFailure(ctx, constants.AuditActionRoleCreated, "创建角色失败", constants.AuditResultFailed, role, nil, nil, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建角色失败")
|
||||
}
|
||||
|
||||
@@ -98,15 +126,19 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleReques
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取角色失败")
|
||||
}
|
||||
beforeData := roleAuditData(role)
|
||||
|
||||
// 如果修改了角色名,检查是否与其他角色重复
|
||||
if req.RoleName != nil && *req.RoleName != role.RoleName {
|
||||
exists, err := s.roleStore.ExistsByName(ctx, *req.RoleName, id)
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRoleUpdated, "更新角色失败", constants.AuditResultFailed, role, beforeData, nil, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "检查角色名失败")
|
||||
}
|
||||
if exists {
|
||||
return nil, errors.New(errors.CodeRoleNameExists)
|
||||
appErr := errors.New(errors.CodeRoleNameExists)
|
||||
s.recordFailure(ctx, constants.AuditActionRoleUpdated, "拒绝更新重复角色名", constants.AuditResultDenied, role, beforeData, nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
role.RoleName = *req.RoleName
|
||||
}
|
||||
@@ -121,7 +153,16 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleReques
|
||||
|
||||
role.Updater = currentUserID
|
||||
|
||||
if err := s.roleStore.Update(ctx, role); err != nil {
|
||||
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
|
||||
if err := postgres.NewRoleStore(tx).Update(ctx, role); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionRoleUpdated, Summary: "更新角色", Result: constants.AuditResultSuccess,
|
||||
OperatorID: currentUserID, Role: role, BeforeData: beforeData, AfterData: roleAuditData(role),
|
||||
})
|
||||
}); err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRoleUpdated, "更新角色失败", constants.AuditResultFailed, role, beforeData, nil, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新角色失败")
|
||||
}
|
||||
|
||||
@@ -130,7 +171,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleReques
|
||||
|
||||
// Delete 软删除角色
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
_, err := s.roleStore.GetByID(ctx, id)
|
||||
role, err := s.roleStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeRoleNotFound, "角色不存在")
|
||||
@@ -140,19 +181,34 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
|
||||
accountCount, err := s.accountRoleStore.CountByRoleID(ctx, id)
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
|
||||
}
|
||||
|
||||
shopCount, err := s.shopRoleStore.CountByRoleID(ctx, id)
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
|
||||
}
|
||||
|
||||
if accountCount > 0 || shopCount > 0 {
|
||||
return errors.New(errors.CodeRoleInUse, fmt.Sprintf("该角色已分配给 %d 个账号、%d 个店铺,请先移除相关分配后再删除", accountCount, shopCount))
|
||||
appErr := errors.New(errors.CodeRoleInUse, fmt.Sprintf("该角色已分配给 %d 个账号、%d 个店铺,请先移除相关分配后再删除", accountCount, shopCount))
|
||||
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "拒绝删除使用中的角色", constants.AuditResultDenied, role, roleAuditData(role), nil, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
if err := s.roleStore.Delete(ctx, id); err != nil {
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
beforeData := roleAuditData(role)
|
||||
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
|
||||
if err := postgres.NewRoleStore(tx).Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionRoleDeleted, Summary: "删除角色", Result: constants.AuditResultSuccess,
|
||||
OperatorID: operatorID, Role: role, BeforeData: beforeData, AfterData: map[string]any{"deleted": true},
|
||||
})
|
||||
}); err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, beforeData, nil, err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除角色失败")
|
||||
}
|
||||
|
||||
@@ -212,11 +268,14 @@ func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []
|
||||
|
||||
permissions, err := s.permissionStore.GetByIDs(ctx, permIDs)
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "分配角色权限失败", constants.AuditResultFailed, role, nil, nil, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取权限失败")
|
||||
}
|
||||
|
||||
if len(permissions) != len(permIDs) {
|
||||
return nil, errors.New(errors.CodePermissionNotFound, "部分权限不存在")
|
||||
appErr := errors.New(errors.CodePermissionNotFound, "部分权限不存在")
|
||||
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "拒绝分配不存在的权限", constants.AuditResultDenied, role, nil, nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
roleTypeStr := fmt.Sprintf("%d", role.RoleType)
|
||||
@@ -228,12 +287,15 @@ func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []
|
||||
}
|
||||
|
||||
if len(invalidPermIDs) > 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("权限 %v 不适用于此角色类型", invalidPermIDs))
|
||||
appErr := errors.New(errors.CodeInvalidParam, fmt.Sprintf("权限 %v 不适用于此角色类型", invalidPermIDs))
|
||||
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "拒绝分配不适用的权限", constants.AuditResultDenied, role, permissionAuditChanges(permissions, nil, nil), nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
// 批量获取已有权限集合,避免逐条 Exists 查询
|
||||
existingPermIDs, err := s.rolePermissionStore.GetPermIDsByRoleID(ctx, roleID)
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "分配角色权限失败", constants.AuditResultFailed, role, nil, nil, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取已有权限失败")
|
||||
}
|
||||
existingSet := make(map[uint]bool, len(existingPermIDs))
|
||||
@@ -242,6 +304,11 @@ func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []
|
||||
}
|
||||
|
||||
var rps []*model.RolePermission
|
||||
changedPermissions := make([]*model.Permission, 0, len(permissions))
|
||||
permissionByID := make(map[uint]*model.Permission, len(permissions))
|
||||
for _, permission := range permissions {
|
||||
permissionByID[permission.ID] = permission
|
||||
}
|
||||
for _, permID := range permIDs {
|
||||
if existingSet[permID] {
|
||||
continue
|
||||
@@ -252,11 +319,37 @@ func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []
|
||||
PermID: permID,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
if err := s.rolePermissionStore.Create(ctx, rp); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建角色-权限关联失败")
|
||||
}
|
||||
rps = append(rps, rp)
|
||||
changedPermissions = append(changedPermissions, permissionByID[permID])
|
||||
}
|
||||
beforeData := map[string]any{"permission_ids": existingPermIDs}
|
||||
afterData := map[string]any{"permission_ids": appendPermissionIDs(existingPermIDs, rps)}
|
||||
if len(rps) == 0 {
|
||||
return rps, nil
|
||||
}
|
||||
var accountIDs []uint
|
||||
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
|
||||
store := postgres.NewRolePermissionStore(tx, nil)
|
||||
for _, rp := range rps {
|
||||
if err := store.Create(ctx, rp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var err error
|
||||
accountIDs, err = rolePermissionCacheAccountIDs(ctx, tx, role.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionRolePermissionsAssigned, Summary: "分配角色权限", Result: constants.AuditResultSuccess,
|
||||
OperatorID: currentUserID, Role: role, Permissions: permissionAuditChanges(changedPermissions, map[string]any{"assigned": false}, map[string]any{"assigned": true}),
|
||||
BeforeData: beforeData, AfterData: afterData,
|
||||
})
|
||||
}); err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "分配角色权限失败", constants.AuditResultFailed, role, permissionAuditChanges(changedPermissions, map[string]any{"assigned": false}, nil), beforeData, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建角色-权限关联失败")
|
||||
}
|
||||
s.clearRolePermissionCaches(ctx, accountIDs)
|
||||
|
||||
return rps, nil
|
||||
}
|
||||
@@ -288,7 +381,7 @@ func (s *Service) GetPermissions(ctx context.Context, roleID uint) ([]*model.Per
|
||||
|
||||
// RemovePermission 移除角色的权限
|
||||
func (s *Service) RemovePermission(ctx context.Context, roleID, permID uint) error {
|
||||
_, err := s.roleStore.GetByID(ctx, roleID)
|
||||
role, err := s.roleStore.GetByID(ctx, roleID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeRoleNotFound, "角色不存在")
|
||||
@@ -296,16 +389,52 @@ func (s *Service) RemovePermission(ctx context.Context, roleID, permID uint) err
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取角色失败")
|
||||
}
|
||||
|
||||
if err := s.rolePermissionStore.Delete(ctx, roleID, permID); err != nil {
|
||||
existingPermIDs, err := s.rolePermissionStore.GetPermIDsByRoleID(ctx, roleID)
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRolePermissionRemoved, "移除角色权限失败", constants.AuditResultFailed, role, nil, nil, err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取角色权限失败")
|
||||
}
|
||||
if !containsUint(existingPermIDs, permID) {
|
||||
return nil
|
||||
}
|
||||
permission, permissionErr := s.permissionStore.GetByID(ctx, permID)
|
||||
if permissionErr != nil && permissionErr != gorm.ErrRecordNotFound {
|
||||
s.recordFailure(ctx, constants.AuditActionRolePermissionRemoved, "移除角色权限失败", constants.AuditResultFailed, role, nil, map[string]any{"permission_ids": existingPermIDs}, permissionErr)
|
||||
return errors.Wrap(errors.CodeInternalError, permissionErr, "获取待移除权限失败")
|
||||
}
|
||||
changes := []accessauditapp.PermissionChange(nil)
|
||||
if permissionErr == nil {
|
||||
changes = permissionAuditChanges([]*model.Permission{permission}, map[string]any{"assigned": true}, map[string]any{"assigned": false})
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
beforeData := map[string]any{"permission_ids": existingPermIDs}
|
||||
afterData := map[string]any{"permission_ids": removePermissionIDs(existingPermIDs, map[uint]struct{}{permID: {}})}
|
||||
var accountIDs []uint
|
||||
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
|
||||
if err := postgres.NewRolePermissionStore(tx, nil).Delete(ctx, roleID, permID); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
accountIDs, err = rolePermissionCacheAccountIDs(ctx, tx, role.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionRolePermissionRemoved, Summary: "移除角色权限", Result: constants.AuditResultSuccess,
|
||||
OperatorID: operatorID, Role: role, Permissions: changes, BeforeData: beforeData, AfterData: afterData,
|
||||
})
|
||||
}); err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRolePermissionRemoved, "移除角色权限失败", constants.AuditResultFailed, role, changes, beforeData, err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除角色-权限关联失败")
|
||||
}
|
||||
s.clearRolePermissionCaches(ctx, accountIDs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchRemovePermissions 批量移除角色的权限
|
||||
func (s *Service) BatchRemovePermissions(ctx context.Context, roleID uint, permIDs []uint) error {
|
||||
_, err := s.roleStore.GetByID(ctx, roleID)
|
||||
role, err := s.roleStore.GetByID(ctx, roleID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeRoleNotFound, "角色不存在")
|
||||
@@ -313,9 +442,50 @@ func (s *Service) BatchRemovePermissions(ctx context.Context, roleID uint, permI
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取角色失败")
|
||||
}
|
||||
|
||||
if err := s.rolePermissionStore.BatchDelete(ctx, roleID, permIDs); err != nil {
|
||||
existingPermIDs, err := s.rolePermissionStore.GetPermIDsByRoleID(ctx, roleID)
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRolePermissionsBatchRemoved, "批量移除角色权限失败", constants.AuditResultFailed, role, nil, nil, err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取角色权限失败")
|
||||
}
|
||||
removeSet := make(map[uint]struct{}, len(permIDs))
|
||||
actualIDs := make([]uint, 0, len(permIDs))
|
||||
for _, permID := range permIDs {
|
||||
removeSet[permID] = struct{}{}
|
||||
if containsUint(existingPermIDs, permID) {
|
||||
actualIDs = append(actualIDs, permID)
|
||||
}
|
||||
}
|
||||
if len(actualIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
permissions, err := s.permissionStore.GetByIDs(ctx, actualIDs)
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRolePermissionsBatchRemoved, "批量移除角色权限失败", constants.AuditResultFailed, role, nil, map[string]any{"permission_ids": existingPermIDs}, err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取待移除权限失败")
|
||||
}
|
||||
changes := permissionAuditChanges(permissions, map[string]any{"assigned": true}, map[string]any{"assigned": false})
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
beforeData := map[string]any{"permission_ids": existingPermIDs}
|
||||
afterData := map[string]any{"permission_ids": removePermissionIDs(existingPermIDs, removeSet)}
|
||||
var accountIDs []uint
|
||||
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
|
||||
if err := postgres.NewRolePermissionStore(tx, nil).BatchDelete(ctx, roleID, permIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
accountIDs, err = rolePermissionCacheAccountIDs(ctx, tx, role.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionRolePermissionsBatchRemoved, Summary: "批量移除角色权限", Result: constants.AuditResultSuccess,
|
||||
OperatorID: operatorID, Role: role, Permissions: changes, BeforeData: beforeData, AfterData: afterData,
|
||||
})
|
||||
}); err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRolePermissionsBatchRemoved, "批量移除角色权限失败", constants.AuditResultFailed, role, changes, beforeData, err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "批量删除角色-权限关联失败")
|
||||
}
|
||||
s.clearRolePermissionCaches(ctx, accountIDs)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -340,23 +510,37 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
if status == constants.StatusDisabled {
|
||||
accountCount, err := s.accountRoleStore.CountByRoleID(ctx, id)
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
|
||||
}
|
||||
|
||||
shopCount, err := s.shopRoleStore.CountByRoleID(ctx, id)
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
|
||||
}
|
||||
|
||||
if accountCount > 0 || shopCount > 0 {
|
||||
return errors.New(errors.CodeRoleInUse, fmt.Sprintf("该角色已分配给 %d 个账号、%d 个店铺,请先移除相关分配后再禁用", accountCount, shopCount))
|
||||
appErr := errors.New(errors.CodeRoleInUse, fmt.Sprintf("该角色已分配给 %d 个账号、%d 个店铺,请先移除相关分配后再禁用", accountCount, shopCount))
|
||||
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "拒绝禁用使用中的角色", constants.AuditResultDenied, role, roleAuditData(role), nil, appErr)
|
||||
return appErr
|
||||
}
|
||||
}
|
||||
|
||||
beforeData := roleAuditData(role)
|
||||
role.Status = status
|
||||
role.Updater = currentUserID
|
||||
|
||||
if err := s.roleStore.Update(ctx, role); err != nil {
|
||||
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
|
||||
if err := postgres.NewRoleStore(tx).Update(ctx, role); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionRoleStatusUpdated, Summary: "更新角色状态", Result: constants.AuditResultSuccess,
|
||||
OperatorID: currentUserID, Role: role, BeforeData: beforeData, AfterData: roleAuditData(role),
|
||||
})
|
||||
}); err != nil {
|
||||
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, beforeData, nil, err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新角色状态失败")
|
||||
}
|
||||
|
||||
@@ -390,3 +574,95 @@ func contains(availableForRoleTypes, roleTypeStr string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) runAccessTransaction(ctx context.Context, fn func(tx *gorm.DB) error) error {
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "角色权限审计接缝未配置")
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(fn)
|
||||
}
|
||||
|
||||
func (s *Service) recordFailure(
|
||||
ctx context.Context,
|
||||
actionCode, summary, result string,
|
||||
role *model.Role,
|
||||
permissions []accessauditapp.PermissionChange,
|
||||
beforeData map[string]any,
|
||||
originalErr error,
|
||||
) {
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
|
||||
ActionCode: actionCode, Summary: summary, Result: result,
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), Role: role, Permissions: permissions,
|
||||
BeforeData: beforeData,
|
||||
}, originalErr)
|
||||
}
|
||||
|
||||
func roleAuditData(role *model.Role) map[string]any {
|
||||
if role == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"role_name": role.RoleName, "role_desc": role.RoleDesc, "role_type": role.RoleType, "status": role.Status,
|
||||
"default_credit_enabled": role.DefaultCreditEnabled, "default_credit_limit": role.DefaultCreditLimit,
|
||||
}
|
||||
}
|
||||
|
||||
func permissionAuditChanges(permissions []*model.Permission, beforeData, afterData map[string]any) []accessauditapp.PermissionChange {
|
||||
changes := make([]accessauditapp.PermissionChange, 0, len(permissions))
|
||||
for _, permission := range permissions {
|
||||
if permission == nil {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, accessauditapp.PermissionChange{
|
||||
Permission: permission, BeforeData: beforeData, AfterData: afterData,
|
||||
})
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func appendPermissionIDs(existing []uint, additions []*model.RolePermission) []uint {
|
||||
result := append([]uint(nil), existing...)
|
||||
for _, addition := range additions {
|
||||
result = append(result, addition.PermID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func removePermissionIDs(existing []uint, removeSet map[uint]struct{}) []uint {
|
||||
result := make([]uint, 0, len(existing))
|
||||
for _, permissionID := range existing {
|
||||
if _, removed := removeSet[permissionID]; !removed {
|
||||
result = append(result, permissionID)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func containsUint(values []uint, target uint) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rolePermissionCacheAccountIDs(ctx context.Context, tx *gorm.DB, roleID uint) ([]uint, error) {
|
||||
var accountIDs []uint
|
||||
if err := tx.WithContext(ctx).Model(&model.AccountRole{}).
|
||||
Where("role_id = ?", roleID).Distinct().Pluck("account_id", &accountIDs).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询角色关联账号失败")
|
||||
}
|
||||
return accountIDs, nil
|
||||
}
|
||||
|
||||
func (s *Service) clearRolePermissionCaches(ctx context.Context, accountIDs []uint) {
|
||||
if len(accountIDs) == 0 || s.redisClient == nil {
|
||||
return
|
||||
}
|
||||
pipe := s.redisClient.Pipeline()
|
||||
for _, accountID := range accountIDs {
|
||||
pipe.Del(ctx, constants.RedisUserPermissionsKey(accountID))
|
||||
}
|
||||
_, _ = pipe.Exec(ctx)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package shop
|
||||
import (
|
||||
"context"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
@@ -10,16 +11,28 @@ import (
|
||||
"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/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
redisClient *redis.Client
|
||||
accessAudit accessauditapp.Writer
|
||||
shopStore *postgres.ShopStore
|
||||
accountStore *postgres.AccountStore
|
||||
shopRoleStore *postgres.ShopRoleStore
|
||||
roleStore *postgres.RoleStore
|
||||
}
|
||||
|
||||
// SetAccessAudit 注入店铺角色授权的事务、缓存和统一审计边界。
|
||||
func (s *Service) SetAccessAudit(db *gorm.DB, redisClient *redis.Client, writer accessauditapp.Writer) {
|
||||
s.db = db
|
||||
s.redisClient = redisClient
|
||||
s.accessAudit = writer
|
||||
}
|
||||
|
||||
func New(
|
||||
shopStore *postgres.ShopStore,
|
||||
accountStore *postgres.AccountStore,
|
||||
@@ -290,36 +303,90 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
shop, err := s.shopStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeShopNotFound, "店铺不存在")
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "店铺删除审计接缝未配置")
|
||||
}
|
||||
var shop *model.Shop
|
||||
var parent *model.Shop
|
||||
var accounts []*model.Account
|
||||
var accountIDs []uint
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var locked model.Shop
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeShopNotFound, "店铺不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "获取店铺失败")
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取店铺失败")
|
||||
}
|
||||
|
||||
accounts, err := s.accountStore.GetByShopID(ctx, shop.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询店铺账号失败")
|
||||
}
|
||||
|
||||
if len(accounts) > 0 {
|
||||
accountIDs := make([]uint, 0, len(accounts))
|
||||
shop = &locked
|
||||
parent = loadDeletedShopParent(tx, locked.ParentID)
|
||||
if err := tx.Where("shop_id = ?", locked.ID).Find(&accounts).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺账号失败")
|
||||
}
|
||||
accountChanges := make([]accessauditapp.AccountChange, 0, len(accounts))
|
||||
accountIDs = make([]uint, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
accountIDs = append(accountIDs, account.ID)
|
||||
accountChanges = append(accountChanges, accessauditapp.AccountChange{
|
||||
Account: account, BeforeData: map[string]any{"status": account.Status}, AfterData: map[string]any{"status": constants.StatusDisabled},
|
||||
})
|
||||
}
|
||||
if err := s.accountStore.BulkUpdateStatus(ctx, accountIDs, constants.StatusDisabled, currentUserID); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "禁用店铺账号失败")
|
||||
if len(accountIDs) > 0 {
|
||||
if err := postgres.NewAccountStore(tx, nil).BulkUpdateStatus(ctx, accountIDs, constants.StatusDisabled, currentUserID); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "禁用店铺账号失败")
|
||||
}
|
||||
}
|
||||
if err := tx.Delete(&model.Shop{}, id).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "删除店铺失败")
|
||||
}
|
||||
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopDeleted, Summary: "删除店铺", OperatorID: currentUserID,
|
||||
Shop: shop, ParentShop: parent, Accounts: accountChanges,
|
||||
BeforeData: map[string]any{"deleted": false}, AfterData: map[string]any{"deleted": true},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺已删除",
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺删除审计失败")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if shop != nil {
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopDeleted, Summary: "删除店铺失败", Result: shopRoleFailureResult(err),
|
||||
OperatorID: currentUserID, Shop: shop, ParentShop: parent, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.shopStore.Delete(ctx, id); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除店铺失败")
|
||||
}
|
||||
|
||||
s.clearDeletedShopCaches(ctx, shop.ID, shop.ParentID, accountIDs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) clearDeletedShopCaches(ctx context.Context, shopID uint, parentID *uint, accountIDs []uint) {
|
||||
if s.redisClient == nil {
|
||||
return
|
||||
}
|
||||
keys := []string{constants.RedisShopSubordinatesKey(shopID)}
|
||||
if parentID != nil {
|
||||
keys = append(keys, constants.RedisShopSubordinatesKey(*parentID))
|
||||
}
|
||||
for _, accountID := range accountIDs {
|
||||
keys = append(keys, constants.RedisUserPermissionsKey(accountID))
|
||||
}
|
||||
_ = s.redisClient.Del(ctx, keys...).Err()
|
||||
}
|
||||
|
||||
func loadDeletedShopParent(tx *gorm.DB, parentID *uint) *model.Shop {
|
||||
if parentID == nil {
|
||||
return nil
|
||||
}
|
||||
var parent model.Shop
|
||||
if err := tx.Unscoped().First(&parent, *parentID).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &parent
|
||||
}
|
||||
|
||||
// GetSubordinateShopIDs 获取下级店铺 ID 列表(包含自己)
|
||||
func (s *Service) GetSubordinateShopIDs(ctx context.Context, shopID uint) ([]uint, error) {
|
||||
return s.shopStore.GetSubordinateShopIDs(ctx, shopID)
|
||||
|
||||
@@ -2,12 +2,18 @@ package shop
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"slices"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (s *Service) AssignRolesToShop(ctx context.Context, shopID uint, roleIDs []uint) ([]*model.ShopRole, error) {
|
||||
@@ -20,52 +26,11 @@ func (s *Service) AssignRolesToShop(ctx context.Context, shopID uint, roleIDs []
|
||||
return nil, errors.New(errors.CodeNotFound, "店铺不存在")
|
||||
}
|
||||
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
|
||||
if len(roleIDs) == 0 {
|
||||
if err := s.shopRoleStore.DeleteByShopID(ctx, shopID); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "清空店铺角色失败")
|
||||
}
|
||||
return []*model.ShopRole{}, nil
|
||||
}
|
||||
|
||||
roles, err := s.roleStore.GetByIDs(ctx, roleIDs)
|
||||
shopRoles, changedRoles, err := s.assignShopRoles(ctx, shop, middleware.GetUserIDFromContext(ctx), roleIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询角色失败")
|
||||
s.recordShopRoleFailure(ctx, constants.AuditActionShopRolesAssigned, shop, changedRoles, err)
|
||||
return nil, err
|
||||
}
|
||||
if len(roles) != len(roleIDs) {
|
||||
return nil, errors.New(errors.CodeNotFound, "部分角色不存在")
|
||||
}
|
||||
|
||||
for _, role := range roles {
|
||||
if role.RoleType != constants.RoleTypeCustomer {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "店铺只能分配客户角色")
|
||||
}
|
||||
if role.Status != constants.StatusEnabled {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "角色已禁用")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.shopRoleStore.DeleteByShopID(ctx, shopID); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "删除现有店铺角色失败")
|
||||
}
|
||||
|
||||
shopRoles := make([]*model.ShopRole, 0, len(roleIDs))
|
||||
for _, roleID := range roleIDs {
|
||||
shopRole := &model.ShopRole{
|
||||
ShopID: shop.ID,
|
||||
RoleID: roleID,
|
||||
Status: constants.StatusEnabled,
|
||||
Creator: currentUserID,
|
||||
Updater: currentUserID,
|
||||
}
|
||||
shopRoles = append(shopRoles, shopRole)
|
||||
}
|
||||
|
||||
if err := s.shopRoleStore.BatchCreate(ctx, shopRoles); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "批量创建店铺角色失败")
|
||||
}
|
||||
|
||||
return shopRoles, nil
|
||||
}
|
||||
|
||||
@@ -132,14 +97,234 @@ func (s *Service) DeleteShopRole(ctx context.Context, shopID, roleID uint) error
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := s.shopStore.GetByID(ctx, shopID)
|
||||
shop, err := s.shopStore.GetByID(ctx, shopID)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeNotFound, "店铺不存在")
|
||||
}
|
||||
|
||||
if err := s.shopRoleStore.Delete(ctx, shopID, roleID); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除店铺角色失败")
|
||||
role, err := s.removeShopRole(ctx, shop, middleware.GetUserIDFromContext(ctx), roleID)
|
||||
if err != nil {
|
||||
roles := []*model.Role(nil)
|
||||
if role != nil {
|
||||
roles = []*model.Role{role}
|
||||
}
|
||||
s.recordShopRoleFailure(ctx, constants.AuditActionShopRoleDeleted, shop, roles, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) assignShopRoles(ctx context.Context, shop *model.Shop, operatorID uint, requested []uint) ([]*model.ShopRole, []*model.Role, error) {
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return nil, nil, errors.New(errors.CodeInvalidStatus, "店铺角色审计接缝未配置")
|
||||
}
|
||||
var shopRoles []*model.ShopRole
|
||||
var changedRoles []*model.Role
|
||||
var accountIDs []uint
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&model.Shop{}, shop.ID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定店铺角色关系失败")
|
||||
}
|
||||
shopRoleStore := postgres.NewShopRoleStore(tx, nil)
|
||||
roleStore := postgres.NewRoleStore(tx)
|
||||
beforeIDs, err := shopRoleStore.GetRoleIDsByShopID(ctx, shop.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺现有角色失败")
|
||||
}
|
||||
requestedRoles, err := validateShopRoles(ctx, roleStore, requested)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changedRoles, err = loadChangedRoles(ctx, roleStore, beforeIDs, requested, requestedRoles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := shopRoleStore.DeleteByShopID(ctx, shop.ID); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "删除现有店铺角色失败")
|
||||
}
|
||||
shopRoles = make([]*model.ShopRole, 0, len(requested))
|
||||
for _, roleID := range requested {
|
||||
shopRoles = append(shopRoles, &model.ShopRole{ShopID: shop.ID, RoleID: roleID, Status: constants.StatusEnabled, Creator: operatorID, Updater: operatorID})
|
||||
}
|
||||
if err := shopRoleStore.BatchCreate(ctx, shopRoles); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "批量创建店铺角色失败")
|
||||
}
|
||||
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopRolesAssigned, Summary: "分配店铺默认角色",
|
||||
OperatorID: operatorID, Shop: shop, Roles: shopRoleChanges(changedRoles, beforeIDs, requested),
|
||||
BeforeData: map[string]any{"role_ids": sortedShopRoleIDs(beforeIDs)},
|
||||
AfterData: map[string]any{"role_ids": sortedShopRoleIDs(requested)},
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺角色审计失败")
|
||||
}
|
||||
accountIDs, err = shopPermissionCacheAccountIDs(ctx, tx, shop.ID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, changedRoles, err
|
||||
}
|
||||
s.clearShopPermissionCaches(ctx, accountIDs)
|
||||
return shopRoles, changedRoles, nil
|
||||
}
|
||||
|
||||
func (s *Service) removeShopRole(ctx context.Context, shop *model.Shop, operatorID, roleID uint) (*model.Role, error) {
|
||||
if s.db == nil || s.accessAudit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "店铺角色审计接缝未配置")
|
||||
}
|
||||
var removed *model.Role
|
||||
var accountIDs []uint
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&model.Shop{}, shop.ID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定店铺角色关系失败")
|
||||
}
|
||||
shopRoles := postgres.NewShopRoleStore(tx, nil)
|
||||
beforeIDs, err := shopRoles.GetRoleIDsByShopID(ctx, shop.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺现有角色失败")
|
||||
}
|
||||
if !slices.Contains(beforeIDs, roleID) {
|
||||
return nil
|
||||
}
|
||||
removed, err = postgres.NewRoleStore(tx).GetByID(ctx, roleID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询待移除店铺角色失败")
|
||||
}
|
||||
if err := shopRoles.Delete(ctx, shop.ID, roleID); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "删除店铺角色失败")
|
||||
}
|
||||
afterIDs := removeShopRoleID(beforeIDs, roleID)
|
||||
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: constants.AuditActionShopRoleDeleted, Summary: "删除店铺默认角色",
|
||||
OperatorID: operatorID, Shop: shop,
|
||||
Roles: []accessauditapp.RoleChange{{Role: removed, BeforeData: map[string]any{"assigned": true}, AfterData: map[string]any{"assigned": false}}},
|
||||
BeforeData: map[string]any{"role_ids": sortedShopRoleIDs(beforeIDs)},
|
||||
AfterData: map[string]any{"role_ids": sortedShopRoleIDs(afterIDs)},
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺角色审计失败")
|
||||
}
|
||||
accountIDs, err = shopPermissionCacheAccountIDs(ctx, tx, shop.ID)
|
||||
return err
|
||||
})
|
||||
if err == nil {
|
||||
s.clearShopPermissionCaches(ctx, accountIDs)
|
||||
}
|
||||
return removed, err
|
||||
}
|
||||
|
||||
func validateShopRoles(ctx context.Context, store *postgres.RoleStore, roleIDs []uint) ([]*model.Role, error) {
|
||||
if len(roleIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
roles, err := store.GetByIDs(ctx, roleIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询角色失败")
|
||||
}
|
||||
if len(roles) != len(roleIDs) {
|
||||
return nil, errors.New(errors.CodeNotFound, "部分角色不存在")
|
||||
}
|
||||
for _, role := range roles {
|
||||
if role.RoleType != constants.RoleTypeCustomer {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "店铺只能分配客户角色")
|
||||
}
|
||||
if role.Status != constants.StatusEnabled {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "角色已禁用")
|
||||
}
|
||||
}
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func loadChangedRoles(ctx context.Context, store *postgres.RoleStore, beforeIDs, afterIDs []uint, afterRoles []*model.Role) ([]*model.Role, error) {
|
||||
changedIDs := make([]uint, 0, len(beforeIDs)+len(afterIDs))
|
||||
before, after := shopRoleIDSet(beforeIDs), shopRoleIDSet(afterIDs)
|
||||
for _, id := range beforeIDs {
|
||||
if !after[id] {
|
||||
changedIDs = append(changedIDs, id)
|
||||
}
|
||||
}
|
||||
for _, role := range afterRoles {
|
||||
if !before[role.ID] {
|
||||
changedIDs = append(changedIDs, role.ID)
|
||||
}
|
||||
}
|
||||
roles, err := store.GetByIDs(ctx, changedIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询变更角色失败")
|
||||
}
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func shopPermissionCacheAccountIDs(ctx context.Context, tx *gorm.DB, shopID uint) ([]uint, error) {
|
||||
var accountIDs []uint
|
||||
if err := tx.WithContext(ctx).Model(&model.Account{}).Where("shop_id = ?", shopID).Pluck("id", &accountIDs).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺账号失败")
|
||||
}
|
||||
return accountIDs, nil
|
||||
}
|
||||
|
||||
func (s *Service) clearShopPermissionCaches(ctx context.Context, accountIDs []uint) {
|
||||
if len(accountIDs) == 0 || s.redisClient == nil {
|
||||
return
|
||||
}
|
||||
keys := make([]string, 0, len(accountIDs))
|
||||
for _, accountID := range accountIDs {
|
||||
keys = append(keys, constants.RedisUserPermissionsKey(accountID))
|
||||
}
|
||||
_ = s.redisClient.Del(ctx, keys...).Err()
|
||||
}
|
||||
|
||||
func (s *Service) recordShopRoleFailure(ctx context.Context, action string, shop *model.Shop, roles []*model.Role, originalErr error) {
|
||||
changes := make([]accessauditapp.RoleChange, 0, len(roles))
|
||||
for _, role := range roles {
|
||||
changes = append(changes, accessauditapp.RoleChange{Role: role})
|
||||
}
|
||||
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
|
||||
ActionCode: action, Summary: "店铺角色操作失败", Result: shopRoleFailureResult(originalErr),
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), Shop: shop, Roles: changes,
|
||||
}, originalErr)
|
||||
}
|
||||
|
||||
func shopRoleChanges(roles []*model.Role, beforeIDs, afterIDs []uint) []accessauditapp.RoleChange {
|
||||
before, after := shopRoleIDSet(beforeIDs), shopRoleIDSet(afterIDs)
|
||||
changes := make([]accessauditapp.RoleChange, 0, len(roles))
|
||||
for _, role := range roles {
|
||||
changes = append(changes, accessauditapp.RoleChange{
|
||||
Role: role, BeforeData: map[string]any{"assigned": before[role.ID]}, AfterData: map[string]any{"assigned": after[role.ID]},
|
||||
})
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func shopRoleFailureResult(err error) string {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
switch appErr.Code {
|
||||
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeRoleNotFound:
|
||||
return constants.AuditResultDenied
|
||||
}
|
||||
}
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
|
||||
func shopRoleIDSet(ids []uint) map[uint]bool {
|
||||
set := make(map[uint]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
set[id] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func sortedShopRoleIDs(ids []uint) []uint {
|
||||
result := append([]uint(nil), ids...)
|
||||
slices.Sort(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func removeShopRoleID(ids []uint, removed uint) []uint {
|
||||
result := make([]uint, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id != removed {
|
||||
result = append(result, id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
@@ -42,6 +43,11 @@ func (h *DeviceImportHandler) handleDeviceBatchAllocation(ctx context.Context, t
|
||||
UserID: task.Creator, UserType: task.OperatorType, Username: task.CreatorName,
|
||||
ShopID: valueOrZero(task.OperatorShopID), SubordinateShopIDs: shopScope,
|
||||
})
|
||||
workerCtx = auditcontext.With(workerCtx, auditcontext.Context{
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeDeviceImport,
|
||||
ActorName: "设备CSV批量操作任务", Source: constants.AuditSourceWorker,
|
||||
CorrelationID: task.TaskNo,
|
||||
})
|
||||
result, err := h.executeDeviceBatchAllocation(workerCtx, task, rows)
|
||||
if err != nil {
|
||||
_ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, err.Error())
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -17,12 +19,15 @@ func startGatewayAttempt(ctx context.Context, repository *integrationlog.Reposit
|
||||
return nil, errors.New(errors.CodeInternalError, "Gateway Integration Log 未配置")
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(cardID), 10)
|
||||
integrationID := uuid.NewString()
|
||||
triggerSource := constants.CardObservationSourcePolling
|
||||
triggerScene := scene
|
||||
return repository.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
|
||||
IntegrationID: integrationID,
|
||||
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: operation, ResourceType: constants.AssetTypeIotCard, ResourceID: &resourceID,
|
||||
TriggerSource: &triggerSource, TriggerScene: &triggerScene,
|
||||
TriggerSource: &triggerSource, TriggerScene: &triggerScene, TriggerSeries: &integrationID,
|
||||
CorrelationID: &integrationID,
|
||||
RequestSummary: map[string]any{"card_id": cardID}, Metadata: map[string]any{"scene": scene},
|
||||
InitialResult: constants.IntegrationResultPending,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user