固化七月迭代审计治理进展以隔离线上热修

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:
2026-08-03 09:47:22 +08:00
parent cf2ff0ac1c
commit b3499adfca
114 changed files with 16961 additions and 2782 deletions

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

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

View File

@@ -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, "写入代理充值支付确认事件失败")

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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}
}

View File

@@ -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(&current).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

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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)

View File

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

View File

@@ -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
}

View File

@@ -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/AIntegration 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

View File

@@ -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, "资产审计服务未配置")
}

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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 != "" {

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View File

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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
})

View File

@@ -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: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
})

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

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

View File

@@ -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:"状态名称(中文)"`
}

View 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或调查节点"`
}

View File

@@ -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 调整既有店铺实际信用额度参数。

View 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("调额请求不应暴露由服务端管理的乐观锁版本")
}
}
}

View File

@@ -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"`

View File

@@ -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"`

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

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

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

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

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

View 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, "聚合外部交互趋势失败")
}

View File

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

View File

@@ -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
View 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

View File

@@ -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 {

View File

@@ -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 = &current
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

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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 = &currentUserID
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 {

View File

@@ -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) {
// 验证企业存在

View File

@@ -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

View File

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

View File

@@ -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 = &current
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) {
// 获取客户信息

View File

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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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())

View File

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