Files
junhong_cmp_fiber/internal/infrastructure/audit/writer.go
break 70e680eb0a
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s
feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
- 新增成对迁移 000223(tb_phone_asset_association,含有效关系部分唯一索引与 down 守卫)与 000224(解绑导入任务表),不回填历史
- H5:need_bind_phone 三支判定(开关关闭完全短路);已有主号幂等建联;十项上限按手机号 advisory 串行化(含换绑到全新号的并发场景);换绑原子迁移与冲突整单回滚;不写遗留列
- 后台:关联列表、单项/批量解绑、CSV 导入解绑(B1–B16),超管/平台 gate + 资产数据范围复核,三态统一文案
- 读侧:卡/设备列表与详情按页一次 IN 聚合;两类导出补「关联手机号」列并保留历史表头反解兼容
- 脱敏:关联审计走独立动作/资源只写脱敏手机号;访问日志手机号类字段脱敏
- 同步主 Spec openspec/specs/phone-asset-association 并归档 AUG26-009,补齐 requirement-evidence 与入口矩阵,context-health 通过
2026-09-15 11:54:56 +08:00

1273 lines
53 KiB
Go

package audit
import (
"context"
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
"github.com/bytedance/sonic"
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
accountauditapp "github.com/break/junhong_cmp_fiber/internal/application/accountaudit"
outboxapp "github.com/break/junhong_cmp_fiber/internal/application/outbox"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
)
// WriteAccountLifecycle 将账号生命周期业务事实转换为统一 Audit Event。
func (w *Writer) WriteAccountLifecycle(ctx context.Context, tx *gorm.DB, audit accountauditapp.LifecycleAudit) error {
if audit.Account == nil || audit.Account.Username == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "账号生命周期审计资源不完整")
}
resources := []ResourceInput{{
Type: constants.AuditResourceAccount, ID: optionalResourceID(audit.Account.ID),
Key: accountResourceKey(audit.Account), DisplayName: audit.Account.Username,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAccountTarget,
IdentitySnapshot: accountIdentity(audit.Account), BeforeData: audit.BeforeData, AfterData: audit.AfterData,
}}
if audit.Shop != nil {
shopID := strconv.FormatUint(uint64(audit.Shop.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceShop, ID: &shopID, Key: shopID, DisplayName: audit.Shop.ShopName,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleAccountScope,
IdentitySnapshot: map[string]any{"id": audit.Shop.ID, "shop_code": audit.Shop.ShopCode, "shop_name": audit.Shop.ShopName, "parent_id": audit.Shop.ParentID, "level": audit.Shop.Level},
})
}
if audit.Enterprise != nil {
enterpriseID := strconv.FormatUint(uint64(audit.Enterprise.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceEnterprise, ID: &enterpriseID, Key: enterpriseID, DisplayName: audit.Enterprise.EnterpriseName,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleAccountScope,
IdentitySnapshot: map[string]any{"id": audit.Enterprise.ID, "enterprise_code": audit.Enterprise.EnterpriseCode, "enterprise_name": audit.Enterprise.EnterpriseName, "owner_shop_id": audit.Enterprise.OwnerShopID},
})
}
for _, role := range audit.Roles {
roleID := strconv.FormatUint(uint64(role.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceRole, ID: &roleID, Key: roleID, DisplayName: role.RoleName,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleAccountRole,
IdentitySnapshot: map[string]any{"id": role.ID, "role_name": role.RoleName, "role_type": role.RoleType, "status": role.Status},
})
}
return w.Append(ctx, tx, AppendInput{
ActionCode: audit.ActionCode, Summary: audit.Summary,
Actor: ActorInput{Kind: constants.AuditActorAccount}, Source: constants.AuditSourceAdminAPI,
ScopeType: constants.AuditScopePlatform, Result: audit.Result,
ErrorCode: audit.ErrorCode, ErrorSummary: audit.ErrorSummary, Resources: resources,
})
}
// WriteAccountSecurity 将不含密码、验证码、Token 或 Cookie 的账号安全事实转换为统一 Audit Event。
func (w *Writer) WriteAccountSecurity(ctx context.Context, tx *gorm.DB, audit accountauditapp.SecurityAudit) error {
if audit.Account == nil || audit.Account.ID == 0 || audit.Account.Username == "" || audit.AuthenticationKey == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "账号安全审计资源不完整")
}
accountID := strconv.FormatUint(uint64(audit.Account.ID), 10)
actorID := audit.ActorID
if actorID == 0 {
actorID = audit.Account.ID
}
resources := []ResourceInput{
{
Type: constants.AuditResourceAccount, ID: &accountID, Key: accountID, DisplayName: audit.Account.Username,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAccountTarget,
IdentitySnapshot: accountIdentity(audit.Account), BeforeData: audit.BeforeData, AfterData: audit.AfterData,
},
{
Type: constants.AuditResourceAuthentication, Key: audit.AuthenticationKey, DisplayName: "认证状态",
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAuthentication,
IdentitySnapshot: audit.Authentication,
},
}
return w.Append(ctx, tx, AppendInput{
ActionCode: audit.ActionCode, Summary: audit.Summary,
Actor: ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(actorID), 10), Name: audit.ActorName},
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
Result: audit.Result, ErrorCode: audit.ErrorCode, ErrorSummary: audit.ErrorSummary,
Resources: resources,
})
}
func optionalResourceID(id uint) *string {
if id == 0 {
return nil
}
value := strconv.FormatUint(uint64(id), 10)
return &value
}
func accountResourceKey(account *model.Account) string {
if account.ID == 0 {
return account.Username
}
return strconv.FormatUint(uint64(account.ID), 10)
}
func accountIdentity(account *model.Account) map[string]any {
return map[string]any{
"id": account.ID, "username": account.Username, "phone": account.Phone, "user_type": account.UserType,
"shop_id": account.ShopID, "enterprise_id": account.EnterpriseID,
"wecom_userid": account.WeComUserID, "wecom_name": account.WeComName,
}
}
// WriteAccessChange 将账号权限与组织变化转换为统一 Audit Event。
func (w *Writer) WriteAccessChange(ctx context.Context, tx *gorm.DB, change accessauditapp.ChangeAudit) error {
action, ok := w.registry.Action(change.ActionCode)
if !ok {
recordBusinessWriteFailure(ctx, change.ActionCode, accessChangeResourceKey(change), pkgerrors.New(pkgerrors.CodeInvalidParam, "账号权限或组织审计动作未注册"))
return nil
}
if change.OperatorID == 0 {
recordBusinessWriteFailure(ctx, action.Code, accessChangeResourceKey(change), pkgerrors.New(pkgerrors.CodeInvalidParam, "账号权限或组织审计操作者不完整"))
return nil
}
resources, err := accessResources(change, action.PrimaryResource)
if err != nil {
recordBusinessWriteFailure(ctx, action.Code, accessChangeResourceKey(change), err)
return nil
}
result := change.Result
if result == "" {
result = constants.AuditResultSuccess
}
actorKind := change.ActorKind
if actorKind == "" {
actorKind = constants.AuditActorAccount
}
actorName := change.ActorName
if actorName == "" {
actorName = middleware.GetUsernameFromContext(ctx)
}
source := change.Source
if source == "" {
source = constants.AuditSourceAdminAPI
}
scopeType := change.ScopeType
if scopeType == "" {
scopeType = constants.AuditScopePlatform
}
return w.Append(ctx, tx, AppendInput{
ActionCode: action.Code, Summary: change.Summary,
Actor: ActorInput{
Kind: actorKind, ID: strconv.FormatUint(uint64(change.OperatorID), 10), Name: actorName,
},
Source: source, ScopeType: scopeType,
Result: result, ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary, Resources: resources,
})
}
func accessChangeResourceKey(change accessauditapp.ChangeAudit) string {
if change.PersonalCustomer != nil {
return strconv.FormatUint(uint64(change.PersonalCustomer.ID), 10)
}
if change.Account != nil {
return accountResourceKey(change.Account)
}
if change.Shop != nil {
return shopResourceKey(change.Shop)
}
if change.Enterprise != nil {
return enterpriseResourceKey(change.Enterprise)
}
if change.Role != nil {
return strconv.FormatUint(uint64(change.Role.ID), 10)
}
return ""
}
func accessResources(change accessauditapp.ChangeAudit, primaryResource string) ([]ResourceInput, error) {
resources := make([]ResourceInput, 0, 2+len(change.Accounts)+len(change.Cards)+len(change.CardAuthorizations)+len(change.Devices)+len(change.DeviceBindings)+len(change.DeviceAuthorizations)+len(change.PersonalPhones)+len(change.PersonalOpenIDs)+len(change.PersonalDevices)+len(change.PersonalICCIDs)+len(change.PhoneAssociations)*2+len(change.Roles)+len(change.Permissions))
switch primaryResource {
case constants.AuditResourceAccount:
if change.Account == nil || (change.Account.ID == 0 && change.Account.Username == "") {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "账号授权审计资源不完整")
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourceAccount, ID: optionalResourceID(change.Account.ID),
Key: accountResourceKey(change.Account), DisplayName: change.Account.Username,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAccountTarget,
IdentitySnapshot: accountIdentity(change.Account), BeforeData: change.BeforeData, AfterData: change.AfterData,
})
if change.Shop != nil {
resources = append(resources, ResourceInput{
Type: constants.AuditResourceShop, ID: optionalResourceID(change.Shop.ID),
Key: shopResourceKey(change.Shop), DisplayName: change.Shop.ShopName,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleAccountScope,
IdentitySnapshot: map[string]any{
"id": change.Shop.ID, "shop_code": change.Shop.ShopCode, "shop_name": change.Shop.ShopName,
"parent_id": change.Shop.ParentID, "level": change.Shop.Level,
},
})
}
case constants.AuditResourceShop:
if change.Shop == nil || (change.Shop.ID == 0 && change.Shop.ShopCode == "") {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "店铺授权审计资源不完整")
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourceShop, ID: optionalResourceID(change.Shop.ID),
Key: shopResourceKey(change.Shop), DisplayName: change.Shop.ShopName,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleShopTarget,
IdentitySnapshot: map[string]any{
"id": change.Shop.ID, "shop_code": change.Shop.ShopCode, "shop_name": change.Shop.ShopName,
"parent_id": change.Shop.ParentID, "level": change.Shop.Level,
},
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: change.SubjectVisibility, SubjectSummary: change.SubjectSummary, SubjectData: change.SubjectData,
})
if change.ParentShop != nil {
resources = append(resources, ResourceInput{
Type: constants.AuditResourceShop, ID: optionalResourceID(change.ParentShop.ID),
Key: shopResourceKey(change.ParentShop), DisplayName: change.ParentShop.ShopName,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopParent,
SubjectVisibility: constants.AuditSubjectInternalOnly,
IdentitySnapshot: map[string]any{
"id": change.ParentShop.ID, "shop_code": change.ParentShop.ShopCode, "shop_name": change.ParentShop.ShopName,
"parent_id": change.ParentShop.ParentID, "level": change.ParentShop.Level,
},
})
}
case constants.AuditResourceEnterprise:
if change.Enterprise == nil || (change.Enterprise.ID == 0 && change.Enterprise.EnterpriseCode == "") {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业审计资源不完整")
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourceEnterprise, ID: optionalResourceID(change.Enterprise.ID),
Key: enterpriseResourceKey(change.Enterprise), DisplayName: change.Enterprise.EnterpriseName,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleEnterpriseTarget,
IdentitySnapshot: enterpriseIdentity(change.Enterprise), BeforeData: change.BeforeData, AfterData: change.AfterData,
})
if change.Shop != nil {
resources = append(resources, ResourceInput{
Type: constants.AuditResourceShop, ID: optionalResourceID(change.Shop.ID),
Key: shopResourceKey(change.Shop), DisplayName: change.Shop.ShopName,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleEnterpriseOwnerShop,
IdentitySnapshot: map[string]any{
"id": change.Shop.ID, "shop_code": change.Shop.ShopCode, "shop_name": change.Shop.ShopName,
"parent_id": change.Shop.ParentID, "level": change.Shop.Level,
},
})
}
case constants.AuditResourcePersonalCustomer:
if change.PersonalCustomer == nil || change.PersonalCustomer.ID == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户审计资源不完整")
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourcePersonalCustomer, ID: optionalResourceID(change.PersonalCustomer.ID),
Key: strconv.FormatUint(uint64(change.PersonalCustomer.ID), 10), DisplayName: change.PersonalCustomer.Nickname,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePersonalCustomerTarget,
IdentitySnapshot: personalCustomerIdentity(change.PersonalCustomer), BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: change.SubjectVisibility, SubjectSummary: change.SubjectSummary, SubjectData: change.SubjectData,
})
case constants.AuditResourcePhoneAssetAssociation:
if len(change.PhoneAssociations) == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "手机号—资产关联审计资源不完整")
}
for index, item := range change.PhoneAssociations {
if item.AssociationID == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "手机号—资产关联审计资源不完整")
}
relation := constants.AuditResourceRelationReference
if index == 0 {
relation = constants.AuditResourceRelationPrimary
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourcePhoneAssetAssociation, ID: optionalResourceID(item.AssociationID),
Key: strconv.FormatUint(uint64(item.AssociationID), 10), DisplayName: item.PhoneMasked,
Relation: relation, Role: constants.AuditResourceRolePhoneAssetAssociationTarget,
IdentitySnapshot: map[string]any{
"id": item.AssociationID, "phone_masked": item.PhoneMasked,
"asset_type": item.AssetType, "asset_id": item.AssetID, "status": item.Status,
"source": item.Source, "invalidated_at": item.InvalidatedAt,
"invalidation_method": item.InvalidationMethod, "invalidation_reason": item.InvalidationReason,
},
BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
// 关联指向的资产以参考资源落事件,便于按资产回溯;资产标识缺失时不写资产资源。
if item.AssetType == "" || item.AssetID == 0 {
continue
}
assetID := strconv.FormatUint(uint64(item.AssetID), 10)
resources = append(resources, ResourceInput{
Type: item.AssetType, ID: &assetID, Key: assetID, DisplayName: item.AssetDisplayName,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePhoneAssetAssociationAsset,
IdentitySnapshot: map[string]any{"id": item.AssetID},
SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
}
for index, item := range change.Accounts {
if item.Account == nil || (item.Account.ID == 0 && item.Account.Username == "") {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "组织关联账号审计资源不完整")
}
relation := item.Relation
if relation == "" {
relation = constants.AuditResourceRelationAffected
}
resourceRole := item.Role
if resourceRole == "" {
resourceRole = constants.AuditResourceRoleShopAccount
if primaryResource == constants.AuditResourceEnterprise {
resourceRole = constants.AuditResourceRoleEnterpriseAccount
}
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourceAccount, ID: optionalResourceID(item.Account.ID),
Key: accountResourceKey(item.Account), DisplayName: item.Account.Username,
Relation: relation, Role: resourceRole, IdentitySnapshot: accountIdentity(item.Account),
BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
for index, item := range change.Cards {
if item.Card == nil || (item.Card.ID == 0 && item.Card.ICCID == "") {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业关联卡审计资源不完整")
}
relation := item.Relation
if relation == "" {
relation = constants.AuditResourceRelationAffected
}
role := item.Role
if role == "" {
role = constants.AuditResourceRoleEnterpriseAuthorizedCard
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourceIotCard, ID: optionalResourceID(item.Card.ID),
Key: iotCardResourceKey(item.Card), DisplayName: item.Card.ICCID,
Relation: relation, Role: role, IdentitySnapshot: iotCardIdentity(item.Card),
BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: item.SubjectVisibility, SubjectSummary: item.SubjectSummary,
SubjectData: item.SubjectData, SortOrder: index + 1,
})
}
for index, item := range change.CardAuthorizations {
if item.Authorization == nil || item.Authorization.ID == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业卡授权审计资源不完整")
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourceEnterpriseCardAuthorization, ID: optionalResourceID(item.Authorization.ID),
Key: strconv.FormatUint(uint64(item.Authorization.ID), 10), DisplayName: "企业卡授权记录",
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleEnterpriseCardAuthorization,
IdentitySnapshot: enterpriseCardAuthorizationIdentity(item.Authorization),
BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
for index, item := range change.Devices {
if item.Device == nil || (item.Device.ID == 0 && item.Device.VirtualNo == "") {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业关联设备审计资源不完整")
}
relation := item.Relation
if relation == "" {
relation = constants.AuditResourceRelationAffected
}
role := item.Role
if role == "" {
role = constants.AuditResourceRoleEnterpriseAuthorizedDevice
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourceDevice, ID: optionalResourceID(item.Device.ID),
Key: deviceResourceKey(item.Device), DisplayName: item.Device.VirtualNo,
Relation: relation, Role: role, IdentitySnapshot: deviceIdentity(item.Device),
BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: item.SubjectVisibility, SubjectSummary: item.SubjectSummary,
SubjectData: item.SubjectData, SortOrder: index + 1,
})
}
for index, item := range change.DeviceBindings {
if item.Binding == nil || item.Binding.ID == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业设备卡槽绑定审计资源不完整")
}
relation := item.Relation
if relation == "" {
relation = constants.AuditResourceRelationReference
}
role := item.Role
if role == "" {
role = constants.AuditResourceRoleEnterpriseDeviceBinding
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourceDeviceSIMBinding, ID: optionalResourceID(item.Binding.ID),
Key: strconv.FormatUint(uint64(item.Binding.ID), 10), DisplayName: "设备卡槽绑定",
Relation: relation, Role: role, IdentitySnapshot: deviceSimBindingIdentity(item.Binding),
BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
for index, item := range change.DeviceAuthorizations {
if item.Authorization == nil || item.Authorization.ID == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业设备授权审计资源不完整")
}
relation := item.Relation
if relation == "" {
relation = constants.AuditResourceRelationAffected
}
role := item.Role
if role == "" {
role = constants.AuditResourceRoleEnterpriseDeviceAuthorization
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourceEnterpriseDeviceAuthorization, ID: optionalResourceID(item.Authorization.ID),
Key: strconv.FormatUint(uint64(item.Authorization.ID), 10), DisplayName: "企业设备授权记录",
Relation: relation, Role: role, IdentitySnapshot: enterpriseDeviceAuthorizationIdentity(item.Authorization),
BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
for index, item := range change.PersonalPhones {
if item.Phone == nil || item.Phone.ID == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户手机号审计资源不完整")
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourcePersonalCustomerPhone, ID: optionalResourceID(item.Phone.ID),
Key: strconv.FormatUint(uint64(item.Phone.ID), 10), DisplayName: sanitizer.MaskPhone(item.Phone.Phone),
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerPhone,
IdentitySnapshot: personalCustomerPhoneIdentity(item.Phone), BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
for index, item := range change.PersonalOpenIDs {
if item.OpenID == nil || item.OpenID.ID == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户微信主体审计资源不完整")
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourcePersonalCustomerOpenID, ID: optionalResourceID(item.OpenID.ID),
Key: strconv.FormatUint(uint64(item.OpenID.ID), 10), DisplayName: item.OpenID.AppType,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerWechatIdentity,
IdentitySnapshot: personalCustomerOpenIDIdentity(item.OpenID), BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
for index, item := range change.PersonalDevices {
if item.Binding == nil || item.Binding.ID == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户设备绑定审计资源不完整")
}
relation := item.Relation
if relation == "" {
relation = constants.AuditResourceRelationAffected
}
role := item.Role
if role == "" {
role = constants.AuditResourceRolePersonalCustomerAssetBinding
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourcePersonalCustomerDevice, ID: optionalResourceID(item.Binding.ID),
Key: strconv.FormatUint(uint64(item.Binding.ID), 10), DisplayName: item.Binding.VirtualNo,
Relation: relation, Role: role, IdentitySnapshot: personalCustomerDeviceIdentity(item.Binding),
BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
for index, item := range change.PersonalICCIDs {
if item.Binding == nil || item.Binding.ID == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户 ICCID 绑定审计资源不完整")
}
relation := item.Relation
if relation == "" {
relation = constants.AuditResourceRelationAffected
}
role := item.Role
if role == "" {
role = constants.AuditResourceRolePersonalCustomerAssetBinding
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourcePersonalCustomerICCID, ID: optionalResourceID(item.Binding.ID),
Key: strconv.FormatUint(uint64(item.Binding.ID), 10), DisplayName: item.Binding.ICCID,
Relation: relation, Role: role, IdentitySnapshot: personalCustomerICCIDIdentity(item.Binding),
BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
if primaryResource == constants.AuditResourceRole {
if change.Role == nil || (change.Role.ID == 0 && change.Role.RoleName == "") {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "角色审计资源不完整")
}
resources = append(resources, roleResource(change.Role, change.BeforeData, change.AfterData))
}
for index, item := range change.Roles {
if item.Role == nil || (item.Role.ID == 0 && item.Role.RoleName == "") {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体授权角色审计资源不完整")
}
resourceRole := constants.AuditResourceRoleAccountRole
if primaryResource == constants.AuditResourceShop {
resourceRole = constants.AuditResourceRoleShopRole
}
resources = append(resources, authorizationRoleResource(item, resourceRole, index+1))
}
for index, item := range change.Permissions {
if item.Permission == nil || (item.Permission.ID == 0 && item.Permission.PermCode == "") {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "权限审计资源不完整")
}
relation := constants.AuditResourceRelationAffected
if primaryResource == constants.AuditResourcePermission && index == 0 {
relation = constants.AuditResourceRelationPrimary
}
beforeData, afterData := item.BeforeData, item.AfterData
if primaryResource == constants.AuditResourcePermission && index == 0 {
if beforeData == nil {
beforeData = change.BeforeData
}
if afterData == nil {
afterData = change.AfterData
}
}
resources = append(resources, permissionResource(item.Permission, relation, beforeData, afterData, index+1))
}
if primaryResource == constants.AuditResourcePermission && len(resources) != 1 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "权限 CRUD 审计必须包含一个权限资源")
}
return resources, nil
}
func authorizationRoleResource(change accessauditapp.RoleChange, resourceRole string, sortOrder int) ResourceInput {
return ResourceInput{
Type: constants.AuditResourceRole, ID: optionalResourceID(change.Role.ID),
Key: roleResourceKey(change.Role), DisplayName: change.Role.RoleName,
Relation: constants.AuditResourceRelationAffected, Role: resourceRole,
IdentitySnapshot: map[string]any{
"id": change.Role.ID, "role_name": change.Role.RoleName,
"role_type": change.Role.RoleType, "status": change.Role.Status,
},
BeforeData: change.BeforeData, AfterData: change.AfterData, SortOrder: sortOrder,
}
}
func shopResourceKey(shop *model.Shop) string {
if shop.ID != 0 {
return strconv.FormatUint(uint64(shop.ID), 10)
}
return shop.ShopCode
}
func enterpriseResourceKey(enterprise *model.Enterprise) string {
if enterprise.ID != 0 {
return strconv.FormatUint(uint64(enterprise.ID), 10)
}
return enterprise.EnterpriseCode
}
func enterpriseIdentity(enterprise *model.Enterprise) map[string]any {
return map[string]any{
"id": enterprise.ID, "enterprise_code": enterprise.EnterpriseCode,
"enterprise_name": enterprise.EnterpriseName, "owner_shop_id": enterprise.OwnerShopID,
}
}
func iotCardResourceKey(card *model.IotCard) string {
if card.ID != 0 {
return strconv.FormatUint(uint64(card.ID), 10)
}
return card.ICCID
}
func iotCardIdentity(card *model.IotCard) map[string]any {
return map[string]any{
"id": card.ID, "iccid": card.ICCID, "iccid_19": card.ICCID19, "iccid_20": card.ICCID20,
"virtual_no": card.VirtualNo, "msisdn": card.MSISDN, "carrier_type": card.CarrierType,
"shop_id": card.ShopID, "series_id": card.SeriesID, "generation": card.Generation,
}
}
// IotCardIdentitySnapshot 返回统一 Registry 允许的 IoT 卡身份快照。
func IotCardIdentitySnapshot(card *model.IotCard) map[string]any {
return iotCardIdentity(card)
}
// IotCardResourceKey 返回 IoT 卡审计使用的稳定资源 Key。
func IotCardResourceKey(card *model.IotCard) string {
return iotCardResourceKey(card)
}
func deviceResourceKey(device *model.Device) string {
if device.ID != 0 {
return strconv.FormatUint(uint64(device.ID), 10)
}
return device.VirtualNo
}
func deviceIdentity(device *model.Device) map[string]any {
return map[string]any{
"id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI,
"sn": device.SN, "device_name": device.DeviceName, "device_model": device.DeviceModel,
"device_type": device.DeviceType, "manufacturer": device.Manufacturer,
"shop_id": device.ShopID, "series_id": device.SeriesID, "generation": device.Generation,
}
}
// DeviceIdentitySnapshot 返回统一 Registry 允许的设备身份快照。
func DeviceIdentitySnapshot(device *model.Device) map[string]any {
return deviceIdentity(device)
}
// DeviceResourceKey 返回设备审计使用的稳定资源 Key。
func DeviceResourceKey(device *model.Device) string {
return deviceResourceKey(device)
}
func deviceSimBindingIdentity(binding *model.DeviceSimBinding) map[string]any {
return map[string]any{
"id": binding.ID, "device_id": binding.DeviceID, "slot_position": binding.SlotPosition,
"iot_card_id": binding.IotCardID, "is_current": binding.IsCurrent,
}
}
func enterpriseCardAuthorizationIdentity(auth *model.EnterpriseCardAuthorization) map[string]any {
return map[string]any{
"id": auth.ID, "enterprise_id": auth.EnterpriseID, "card_id": auth.CardID,
"authorized_by": auth.AuthorizedBy, "authorizer_type": auth.AuthorizerType,
"authorized_at": auth.AuthorizedAt, "revoked_by": auth.RevokedBy,
"revoked_at": auth.RevokedAt, "device_auth_id": auth.DeviceAuthID,
}
}
func enterpriseDeviceAuthorizationIdentity(auth *model.EnterpriseDeviceAuthorization) map[string]any {
return map[string]any{
"id": auth.ID, "enterprise_id": auth.EnterpriseID, "device_id": auth.DeviceID,
"authorized_by": auth.AuthorizedBy, "authorizer_type": auth.AuthorizerType,
"authorized_at": auth.AuthorizedAt, "revoked_by": auth.RevokedBy, "revoked_at": auth.RevokedAt,
}
}
func personalCustomerIdentity(customer *model.PersonalCustomer) map[string]any {
return map[string]any{
"id": customer.ID, "nickname": customer.Nickname, "wx_open_id": customer.WxOpenID,
"wx_union_id": customer.WxUnionID, "status": customer.Status,
}
}
// personalCustomerPhoneIdentity 生成个人客户手机号资源身份快照。
// 手机号一律为脱敏值:审计只出现前 3 位 + **** + 后 4 位,资源行 ID 仍可回溯真实记录。
func personalCustomerPhoneIdentity(phone *model.PersonalCustomerPhone) map[string]any {
return map[string]any{
"id": phone.ID, "customer_id": phone.CustomerID, "phone": sanitizer.MaskPhone(phone.Phone),
"is_primary": phone.IsPrimary, "verified_at": phone.VerifiedAt, "status": phone.Status,
}
}
func personalCustomerOpenIDIdentity(openID *model.PersonalCustomerOpenID) map[string]any {
return map[string]any{
"id": openID.ID, "customer_id": openID.CustomerID, "app_id": openID.AppID,
"open_id": openID.OpenID, "union_id": openID.UnionID, "app_type": openID.AppType,
}
}
func personalCustomerDeviceIdentity(binding *model.PersonalCustomerDevice) map[string]any {
return map[string]any{
"id": binding.ID, "customer_id": binding.CustomerID, "virtual_no": binding.VirtualNo,
"bind_at": binding.BindAt, "last_used_at": binding.LastUsedAt, "status": binding.Status,
}
}
func personalCustomerICCIDIdentity(binding *model.PersonalCustomerICCID) map[string]any {
return map[string]any{
"id": binding.ID, "customer_id": binding.CustomerID, "iccid": binding.ICCID,
"iccid_19": binding.ICCID19, "bind_at": binding.BindAt,
"last_used_at": binding.LastUsedAt, "status": binding.Status,
}
}
func roleResource(role *model.Role, beforeData, afterData map[string]any) ResourceInput {
return ResourceInput{
Type: constants.AuditResourceRole, ID: optionalResourceID(role.ID), Key: roleResourceKey(role), DisplayName: role.RoleName,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAccessRole,
IdentitySnapshot: map[string]any{
"id": role.ID, "role_name": role.RoleName, "role_type": role.RoleType, "status": role.Status,
"default_credit_enabled": role.DefaultCreditEnabled, "default_credit_limit": role.DefaultCreditLimit,
},
BeforeData: beforeData, AfterData: afterData,
}
}
func permissionResource(permission *model.Permission, relation string, beforeData, afterData map[string]any, sortOrder int) ResourceInput {
return ResourceInput{
Type: constants.AuditResourcePermission, ID: optionalResourceID(permission.ID), Key: permissionResourceKey(permission), DisplayName: permission.PermName,
Relation: relation, Role: constants.AuditResourceRoleAccessPermission,
IdentitySnapshot: map[string]any{
"id": permission.ID, "perm_name": permission.PermName, "perm_code": permission.PermCode,
"perm_type": permission.PermType, "platform": permission.Platform,
"available_for_role_types": permission.AvailableForRoleTypes, "parent_id": permission.ParentID, "status": permission.Status,
},
BeforeData: beforeData, AfterData: afterData, SortOrder: sortOrder,
}
}
func roleResourceKey(role *model.Role) string {
if role.ID != 0 {
return strconv.FormatUint(uint64(role.ID), 10)
}
return role.RoleName
}
func permissionResourceKey(permission *model.Permission) string {
if permission.ID != 0 {
return strconv.FormatUint(uint64(permission.ID), 10)
}
return permission.PermCode
}
// ActorInput 是事件发生时的真实操作者快照。
type ActorInput struct {
Kind string
ID string
Name string
ShopID *uint
ShopName string
EnterpriseID *uint
EnterpriseName string
}
// WriteSensitiveRead 在返回企业微信明文凭据前同步追加读取审计。
func (w *Writer) WriteSensitiveRead(ctx context.Context, tx *gorm.DB, read wecomapp.SensitiveReadAudit) error {
action, ok := w.registry.Action(constants.AuditActionWeComCredentialsRead)
if !ok || !action.SensitiveRead {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "敏感读取动作未注册")
}
if read.OperatorID == 0 || len(read.Applications) == 0 || len(read.FieldClasses) == 0 {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "敏感读取审计事实不完整")
}
resources := make([]ResourceInput, 0, len(read.Applications))
for index, application := range read.Applications {
relation := constants.AuditResourceRelationReference
if index == 0 {
relation = constants.AuditResourceRelationPrimary
}
resourceID := strconv.FormatUint(uint64(application.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceWeComApplication, ID: &resourceID,
Key: resourceID, DisplayName: application.Name, Relation: relation,
Role: constants.AuditResourceRoleSensitiveReadTarget,
IdentitySnapshot: map[string]any{
"id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID,
"name": application.Name, "status": application.Status,
"credentials_configured": application.CredentialsConfigured,
},
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index,
})
}
return w.Append(ctx, tx, AppendInput{
ActionCode: action.Code, Summary: "读取企业微信应用明文凭据",
Actor: ActorInput{
Kind: constants.AuditActorAccount,
ID: strconv.FormatUint(uint64(read.OperatorID), 10), Name: middleware.GetUsernameFromContext(ctx),
},
Source: action.Source, ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
RequestID: read.RequestID, CorrelationID: read.CorrelationID,
Metadata: map[string]any{"field_classes": read.FieldClasses}, Resources: resources,
})
}
// ResourceInput 是一个独立审计资源的写入事实。
type ResourceInput struct {
Type string
ID *string
Key string
DisplayName string
Relation string
Role string
IdentitySnapshot map[string]any
BeforeData map[string]any
AfterData map[string]any
SubjectVisibility string
SubjectSummary string
SubjectData map[string]any
SortOrder int
}
// AppendInput 是统一 Append Writer 的最小事件输入。
type AppendInput struct {
EventID string
OccurredAt time.Time
ActionCode string
Summary string
Actor ActorInput
Source string
RequestPath string
RequestMethod string
IPAddress string
UserAgent string
ScopeType string
ScopeID string
ScopeName string
Result string
ErrorCode string
ErrorSummary string
RequestID string
CorrelationID string
ParentEventID string
BatchTotal int
SuccessCount int
FailCount int
Metadata map[string]any
Resources []ResourceInput
}
// Writer 只提供不可变 Audit Event 追加能力。
type Writer struct {
registry *Registry
now func() time.Time
}
// NewWriter 创建统一 Audit Event Append Writer。
func NewWriter(registry *Registry, now func() time.Time) *Writer {
if registry == nil {
registry = NewRegistry()
}
if now == nil {
now = time.Now
}
return &Writer{registry: registry, now: now}
}
// WriteConfigChange 将受控系统配置变化转换为统一 Audit Event。
func (w *Writer) WriteConfigChange(ctx context.Context, tx *gorm.DB, change systemconfigapp.ChangeAudit) error {
action, ok := w.registry.ActionByOperation(change.OperationType)
if !ok {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册")
}
if change.OperatorID == 0 || change.ConfigKey == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "系统配置审计资源不完整")
}
result := change.Result
if result == "" {
result = constants.AuditResultSuccess
}
displayName := change.DisplayName
if displayName == "" {
displayName = change.ConfigKey
}
identity := change.Identity
if identity == nil {
identity = map[string]any{"config_key": change.ConfigKey, "module": change.Module}
}
return w.Append(ctx, tx, AppendInput{
ActionCode: action.Code, Summary: change.Description,
Actor: ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(change.OperatorID), 10),
Name: middleware.GetUsernameFromContext(ctx),
},
Source: action.Source, RequestPath: contextString(middleware.GetRequestPathFromContext(ctx)),
RequestMethod: contextString(middleware.GetRequestMethodFromContext(ctx)),
IPAddress: contextString(middleware.GetIPFromContext(ctx)), UserAgent: contextString(middleware.GetUserAgentFromContext(ctx)),
ScopeType: constants.AuditScopePlatform, Result: result,
ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary,
RequestID: change.RequestID, CorrelationID: change.CorrelationID,
Resources: []ResourceInput{{
Type: action.PrimaryResource, ID: change.ResourceID, Key: change.ConfigKey, DisplayName: displayName,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleConfig,
IdentitySnapshot: identity,
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: action.DefaultVisibility,
}},
})
}
// WriteRecovery 将 Outbox 人工恢复裁决转换为统一 Audit Event。
func (w *Writer) WriteRecovery(ctx context.Context, tx *gorm.DB, recovery outboxapp.RecoveryAudit) error {
action, ok := w.registry.ActionByOperation(recovery.OperationType)
if !ok {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册")
}
if recovery.OperatorID == 0 || recovery.BatchID == "" || recovery.Reason == "" || len(recovery.Events) == 0 {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Outbox 恢复审计事实不完整")
}
result := recovery.Result
if result == "" {
result = constants.AuditResultSuccess
}
resources := make([]ResourceInput, 0, len(recovery.Events))
for index, event := range recovery.Events {
if event.ID == 0 || event.EventID == "" || event.EventType == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Outbox 恢复审计资源不完整")
}
relation := constants.AuditResourceRelationAffected
if index == 0 {
relation = constants.AuditResourceRelationPrimary
}
resourceID := strconv.FormatUint(uint64(event.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceOutboxEvent, ID: &resourceID,
Key: event.EventID, DisplayName: event.EventID,
Relation: relation, Role: constants.AuditResourceRoleRecoveryTarget,
IdentitySnapshot: map[string]any{
"event_id": event.EventID, "event_type": event.EventType,
"aggregate_type": event.AggregateType, "aggregate_id": event.AggregateID,
"resource_type": event.ResourceType, "resource_id": event.ResourceID,
"business_key": event.BusinessKey,
},
BeforeData: recoveryStateData(event.BeforeStatus, event.BeforeNextAttempt, event.BeforeLeaseOwner, event.BeforeLeaseExpires),
AfterData: recoveryStateData(event.AfterStatus, event.AfterNextAttempt, event.AfterLeaseOwner, event.AfterLeaseExpires),
SubjectVisibility: action.DefaultVisibility, SortOrder: index,
})
}
return w.Append(ctx, tx, AppendInput{
ActionCode: action.Code, Summary: recovery.Description,
Actor: ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(recovery.OperatorID), 10),
Name: middleware.GetUsernameFromContext(ctx),
},
Source: action.Source, RequestPath: contextString(middleware.GetRequestPathFromContext(ctx)),
RequestMethod: contextString(middleware.GetRequestMethodFromContext(ctx)),
IPAddress: contextString(middleware.GetIPFromContext(ctx)), UserAgent: contextString(middleware.GetUserAgentFromContext(ctx)),
ScopeType: constants.AuditScopePlatform, Result: result,
ErrorCode: recovery.ErrorCode, ErrorSummary: recovery.ErrorSummary,
RequestID: recovery.RequestID, CorrelationID: recovery.CorrelationID,
BatchTotal: len(resources), SuccessCount: recoverySuccessCount(result, len(resources)),
FailCount: recoveryFailCount(result, len(resources)),
Metadata: map[string]any{"batch_id": recovery.BatchID, "reason": recovery.Reason},
Resources: resources,
})
}
// Append 在调用方提供的 GORM 事务中顺序追加事件及资源。
func (w *Writer) Append(ctx context.Context, tx *gorm.DB, input AppendInput) error {
_, err := w.AppendAndGet(ctx, tx, input)
if err != nil {
recordBusinessAppendFailure(ctx, input, err)
}
return nil
}
// AppendAndGet 追加事件并返回已持久化的审计事件,幂等重放返回已有事件。
func (w *Writer) AppendAndGet(ctx context.Context, tx *gorm.DB, input AppendInput) (*model.AuditEvent, error) {
if w == nil || w.registry == nil || tx == nil {
return nil, pkgerrors.New(pkgerrors.CodeInvalidStatus, "统一审计 Writer 未正确配置")
}
input = fillFromContext(ctx, input)
action, ok := w.registry.Action(input.ActionCode)
if !ok {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册")
}
if !actionAllowsOrigin(action, input.Actor.Kind, input.Source) || input.Actor.ID == "" {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计操作者或入口不符合动作注册规则")
}
if !validResult(input.Result) || len(input.Resources) == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计结果非法或缺少资源")
}
resources, err := w.buildResources(input.Resources, action)
if err != nil {
return nil, err
}
metadata, err := safeObject(input.Metadata)
if err != nil {
return nil, err
}
occurredAt := input.OccurredAt
if occurredAt.IsZero() {
occurredAt = w.now().UTC()
}
event := model.AuditEvent{
OccurredAt: occurredAt, Category: action.Category, ActionCode: action.Code, ActionName: action.Name,
Summary: sanitizer.SanitizeText(input.Summary), ActorKind: input.Actor.Kind, ActorID: input.Actor.ID, ActorName: sanitizer.SanitizeText(input.Actor.Name),
ActorShopID: input.Actor.ShopID, ActorShopName: sanitizer.SanitizeText(input.Actor.ShopName),
ActorEnterpriseID: input.Actor.EnterpriseID, ActorEnterpriseName: sanitizer.SanitizeText(input.Actor.EnterpriseName),
Source: input.Source, RequestPath: sanitizer.SanitizeText(input.RequestPath), RequestMethod: input.RequestMethod,
IPAddress: input.IPAddress, UserAgent: sanitizer.SanitizeText(input.UserAgent),
ScopeType: input.ScopeType, ScopeID: input.ScopeID, ScopeName: sanitizer.SanitizeText(input.ScopeName),
Result: input.Result, RiskLevel: action.Risk, RequestID: input.RequestID,
ErrorCode: input.ErrorCode, ErrorSummary: sanitizer.SanitizeText(input.ErrorSummary),
CorrelationID: input.CorrelationID, ParentEventID: input.ParentEventID, Metadata: metadata,
BatchTotal: input.BatchTotal, SuccessCount: input.SuccessCount, FailCount: input.FailCount,
}
event.ContentHash, err = contentHash(event, resources)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "计算审计内容哈希失败")
}
event.EventID = input.EventID
if event.EventID == "" {
event.EventID = "evt_" + uuid.NewString()
}
create := tx.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "event_id"}}, DoNothing: true,
}).Create(&event)
if create.Error != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, create.Error, "写入审计事件失败")
}
if create.RowsAffected == 0 {
var existing model.AuditEvent
if err := tx.WithContext(ctx).Where("event_id = ?", event.EventID).First(&existing).Error; err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "读取已存在审计事件失败")
}
return &existing, nil
}
for index := range resources {
resources[index].AuditEventID = event.ID
resources[index].CreatedAt = event.CreatedAt
}
if err := tx.WithContext(ctx).Create(&resources).Error; err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "写入审计事件资源失败")
}
return &event, nil
}
func actionAllowsOrigin(action ActionDefinition, actor, source string) bool {
if action.AllowedActor == actor && action.Source == source {
return true
}
for _, origin := range action.AllowedOrigins {
if origin.Actor == actor && origin.Source == source {
return true
}
}
return false
}
func fillFromContext(ctx context.Context, input AppendInput) AppendInput {
value := auditcontext.From(ctx)
if input.Actor.Kind == "" {
input.Actor.Kind = value.ActorKind
}
if input.Actor.ID == "" {
input.Actor.ID = value.ActorID
}
if input.Actor.Name == "" {
input.Actor.Name = value.ActorName
}
if input.Actor.ShopID == nil {
input.Actor.ShopID = value.ActorShopID
}
if input.Actor.EnterpriseID == nil {
input.Actor.EnterpriseID = value.ActorEnterpriseID
}
if input.Source == "" {
input.Source = value.Source
}
if input.RequestID == "" {
input.RequestID = value.RequestID
}
if input.CorrelationID == "" {
input.CorrelationID = value.CorrelationID
}
if input.ParentEventID == "" {
input.ParentEventID = value.ParentEventID
}
if input.RequestPath == "" {
input.RequestPath = value.RequestPath
}
if input.RequestMethod == "" {
input.RequestMethod = value.RequestMethod
}
if input.IPAddress == "" {
input.IPAddress = value.IPAddress
}
if input.UserAgent == "" {
input.UserAgent = value.UserAgent
}
return input
}
func (w *Writer) buildResources(inputs []ResourceInput, action ActionDefinition) ([]model.AuditEventResource, error) {
resources := make([]model.AuditEventResource, 0, len(inputs))
primaryCount := 0
for _, input := range inputs {
definition, ok := w.registry.Resource(input.Type)
if !ok {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计资源未注册")
}
if input.Key == "" || input.Relation == "" || input.Role == "" {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计资源关系不完整")
}
if input.Relation == constants.AuditResourceRelationPrimary {
primaryCount++
if input.Type != action.PrimaryResource {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主要资源不符合动作注册规则")
}
}
identity, err := registeredIdentity(input.IdentitySnapshot, definition.IdentityFields)
if err != nil {
return nil, err
}
before, err := safeObject(input.BeforeData)
if err != nil {
return nil, err
}
after, err := safeObject(input.AfterData)
if err != nil {
return nil, err
}
visibility := input.SubjectVisibility
if visibility == "" {
visibility = constants.AuditSubjectInternalOnly
if input.Relation == constants.AuditResourceRelationPrimary {
visibility = action.DefaultVisibility
}
}
subjectDataInput, err := validateSubjectProjection(input, action, visibility)
if err != nil {
return nil, err
}
subjectData, err := safeObject(subjectDataInput)
if err != nil {
return nil, err
}
resources = append(resources, model.AuditEventResource{
ResourceType: input.Type, ResourceID: input.ID, ResourceKey: sanitizer.SanitizeText(input.Key), DisplayName: sanitizer.SanitizeText(input.DisplayName),
Relation: input.Relation, Role: input.Role, IdentitySnapshot: identity,
BeforeData: before, AfterData: after, SubjectVisibility: visibility,
SubjectSummary: sanitizer.SanitizeText(input.SubjectSummary), SubjectData: subjectData, SortOrder: input.SortOrder,
})
}
if primaryCount != 1 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计事件必须且只能有一个主要资源")
}
return resources, nil
}
func validateSubjectProjection(input ResourceInput, action ActionDefinition, visibility string) (map[string]any, error) {
if !containsString(action.AllowedVisibility, visibility) {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体可见级别不符合动作注册规则")
}
switch visibility {
case constants.AuditSubjectInternalOnly:
if input.SubjectSummary != "" || len(input.SubjectData) > 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "内部事件不得写入主体投影")
}
return nil, nil
case constants.AuditSubjectResult:
if input.SubjectSummary == "" || len(input.SubjectData) > 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体结论投影必须仅包含安全摘要")
}
return nil, nil
case constants.AuditSubjectDetail:
if input.SubjectSummary == "" {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体详情投影缺少安全摘要")
}
for field := range input.SubjectData {
if !containsString(action.SubjectFields, field) {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体详情包含未注册字段")
}
}
return input.SubjectData, nil
default:
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体可见级别非法")
}
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func registeredIdentity(value map[string]any, fields []string) (datatypes.JSON, error) {
registered := make(map[string]any, len(fields))
for _, field := range fields {
if item, ok := value[field]; ok {
registered[field] = item
}
}
return boundedObject(registered)
}
func safeObject(value map[string]any) (datatypes.JSON, error) {
if value == nil {
value = map[string]any{}
}
encoded, err := sanitizer.MarshalSummary(value)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "审计 JSON 清理失败")
}
var object map[string]any
if err := sonic.Unmarshal(encoded, &object); err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "审计 JSON 必须是对象")
}
return boundedObject(object)
}
func boundedObject(value map[string]any) (datatypes.JSON, error) {
encoded, err := sonic.ConfigStd.Marshal(value)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "审计 JSON 编码失败")
}
if len(encoded) <= constants.AuditJSONMaxBytes {
return datatypes.JSON(encoded), nil
}
sum := sha256.Sum256(encoded)
truncated, err := sonic.ConfigStd.Marshal(map[string]any{
"truncated": true, "original_bytes": len(encoded), "sha256": hex.EncodeToString(sum[:]),
})
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "审计 JSON 摘要编码失败")
}
return datatypes.JSON(truncated), nil
}
func contentHash(event model.AuditEvent, resources []model.AuditEventResource) (string, error) {
event.EventID = ""
event.ContentHash = ""
event.ID = 0
event.CreatedAt = time.Time{}
for index := range resources {
resources[index].ID = 0
resources[index].AuditEventID = 0
resources[index].CreatedAt = time.Time{}
}
encoded, err := sonic.ConfigStd.Marshal(struct {
Event model.AuditEvent `json:"event"`
Resources []model.AuditEventResource `json:"resources"`
}{Event: event, Resources: resources})
if err != nil {
return "", err
}
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:]), nil
}
func contextString(value *string) string {
if value == nil {
return ""
}
return *value
}
func recoveryStateData(status int, nextAttempt time.Time, leaseOwner *string, leaseExpiresAt *time.Time) map[string]any {
return map[string]any{
"status": status, "status_name": constants.GetOutboxStatusName(status),
"next_attempt_at": nextAttempt, "lease_owner": leaseOwner, "lease_expires_at": leaseExpiresAt,
}
}
func validResult(result string) bool {
switch result {
case constants.AuditResultSuccess, constants.AuditResultFailed, constants.AuditResultDenied,
constants.AuditResultPartial, constants.AuditResultUnknown:
return true
default:
return false
}
}
func recoverySuccessCount(result string, count int) int {
if result == constants.AuditResultSuccess {
return count
}
return 0
}
func recoveryFailCount(result string, count int) int {
if result == constants.AuditResultFailed || result == constants.AuditResultDenied {
return count
}
return 0
}
var _ systemconfigapp.AuditWriter = (*Writer)(nil)
var _ outboxapp.AuditWriter = (*Writer)(nil)
var _ accountauditapp.Writer = (*Writer)(nil)
var _ accessauditapp.Writer = (*Writer)(nil)