UserProfileAuditAppenderService.cs 12.3 KB
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using NCC.Common.Const;
using NCC.Common.Core.Manager;
using NCC.Dependency;
using NCC.Extend.Entitys.lq_user_profile_audit;
using NCC.Extend.Interfaces.UserProfileAudit;
using Newtonsoft.Json;
using NCC.System.Entitys.Permission;
using SqlSugar;
using UAParser;
using Yitter.IdGenerator;

namespace NCC.Extend
{
    /// <summary>
    /// 用户主档字段级审计写入实现(R-001)
    /// </summary>
    public class UserProfileAuditAppenderService : IUserProfileAuditAppender, ITransient
    {
        private static readonly JsonSerializerSettings CloneSettings = new JsonSerializerSettings
        {
            ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
            NullValueHandling = NullValueHandling.Include
        };

        private static readonly HashSet<string> ExcludeProperties = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        {
            nameof(UserEntity.Id),
            nameof(UserEntity.Password),
            nameof(UserEntity.Secretkey),
            nameof(UserEntity.QuickQuery),
            nameof(UserEntity.PropertyJson),
            nameof(UserEntity.CommonMenu),
            nameof(UserEntity.PortalId),
            nameof(UserEntity.ExtensionLong),
            nameof(UserEntity.ExtensionLong1),
            nameof(UserEntity.ExtensionStr),
            nameof(UserEntity.OpenId),
            nameof(UserEntity.FirstLogTime),
            nameof(UserEntity.FirstLogIP),
            nameof(UserEntity.PrevLogTime),
            nameof(UserEntity.PrevLogIP),
            nameof(UserEntity.LastLogTime),
            nameof(UserEntity.LastLogIP),
            nameof(UserEntity.LogSuccessCount),
            nameof(UserEntity.LogErrorCount),
            nameof(UserEntity.CreatorTime),
            nameof(UserEntity.CreatorUserId),
            nameof(UserEntity.LastModifyTime),
            nameof(UserEntity.LastModifyUserId),
            nameof(UserEntity.DeleteMark),
            nameof(UserEntity.DeleteTime),
            nameof(UserEntity.DeleteUserId),
            nameof(UserEntity.ChangePasswordDate),
            nameof(UserEntity.IsAdministrator)
        };

        private static readonly Dictionary<string, string> FieldLabels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            [nameof(UserEntity.Account)] = "账号",
            [nameof(UserEntity.RealName)] = "姓名",
            [nameof(UserEntity.NickName)] = "昵称",
            [nameof(UserEntity.HeadIcon)] = "头像",
            [nameof(UserEntity.Gender)] = "性别",
            [nameof(UserEntity.Birthday)] = "生日",
            [nameof(UserEntity.MobilePhone)] = "手机",
            [nameof(UserEntity.TelePhone)] = "电话",
            [nameof(UserEntity.Landline)] = "固定电话",
            [nameof(UserEntity.Email)] = "邮箱",
            [nameof(UserEntity.Nation)] = "民族",
            [nameof(UserEntity.NativePlace)] = "籍贯",
            [nameof(UserEntity.EntryDate)] = "入职日期",
            [nameof(UserEntity.CertificatesType)] = "证件类型",
            [nameof(UserEntity.CertificatesNumber)] = "证件号码",
            [nameof(UserEntity.Education)] = "文化程度",
            [nameof(UserEntity.UrgentContacts)] = "紧急联系人",
            [nameof(UserEntity.UrgentTelePhone)] = "紧急电话",
            [nameof(UserEntity.PostalAddress)] = "通讯地址",
            [nameof(UserEntity.Signature)] = "自我介绍",
            [nameof(UserEntity.Language)] = "系统语言",
            [nameof(UserEntity.Theme)] = "系统样式",
            [nameof(UserEntity.Description)] = "描述",
            [nameof(UserEntity.SortCode)] = "排序码",
            [nameof(UserEntity.ManagerId)] = "主管",
            [nameof(UserEntity.OrganizeId)] = "组织",
            [nameof(UserEntity.PositionId)] = "岗位",
            [nameof(UserEntity.RoleId)] = "角色",
            [nameof(UserEntity.EnabledMark)] = "启用状态",
            [nameof(UserEntity.Mdid)] = "门店",
            [nameof(UserEntity.Zw)] = "职位",
            [nameof(UserEntity.Fyft)] = "费用分摊",
            [nameof(UserEntity.Gwfl)] = "岗位分类",
            [nameof(UserEntity.Gw)] = "岗位",
            [nameof(UserEntity.AttendanceGroupId)] = "考勤分组",
            [nameof(UserEntity.IsOnJob)] = "是否在职",
            [nameof(UserEntity.LeaveDate)] = "离职日期"
        };

        private readonly ISqlSugarClient _db;

        public UserProfileAuditAppenderService(ISqlSugarClient db)
        {
            _db = db;
        }

        /// <inheritdoc />
        public async Task AppendProfileDiffAsync(
            UserEntity before,
            UserEntity after,
            string action,
            string source,
            string batchId = null,
            string remark = null)
        {
            if (after == null || string.IsNullOrWhiteSpace(after.Id))
            {
                return;
            }

            var (opId, opName, ip, ua) = ResolveOperatorContext();
            var time = DateTime.Now;
            var rows = new List<LqUserProfileAuditEntity>();

            foreach (var prop in typeof(UserEntity).GetProperties(BindingFlags.Public | BindingFlags.Instance))
            {
                if (!prop.CanRead || ExcludeProperties.Contains(prop.Name))
                {
                    continue;
                }

                var oldVal = before == null ? null : prop.GetValue(before);
                var newVal = prop.GetValue(after);
                if (before == null && IsEffectivelyEmpty(newVal))
                {
                    continue;
                }

                if (ValuesEqual(oldVal, newVal))
                {
                    continue;
                }

                var label = FieldLabels.TryGetValue(prop.Name, out var lb) ? lb : prop.Name;
                rows.Add(new LqUserProfileAuditEntity
                {
                    Id = YitIdHelper.NextId().ToString(),
                    TargetUserId = after.Id,
                    OperatorUserId = opId,
                    OperatorUserName = opName,
                    OperateTime = time,
                    Action = action,
                    BatchId = batchId,
                    FieldKey = prop.Name,
                    FieldLabel = label,
                    OldValue = FormatValue(prop.Name, oldVal),
                    NewValue = FormatValue(prop.Name, newVal),
                    Source = source,
                    ClientIp = ip,
                    UserAgent = ua,
                    Remark = remark
                });
            }

            if (rows.Count == 0)
            {
                return;
            }

            await _db.Insertable(rows).ExecuteCommandAsync();
        }

        /// <inheritdoc />
        public async Task AppendPasswordAuditAsync(string targetUserId, string action, string source, string remark = null)
        {
            if (string.IsNullOrWhiteSpace(targetUserId))
            {
                return;
            }

            var (opId, opName, ip, ua) = ResolveOperatorContext();
            await _db.Insertable(new LqUserProfileAuditEntity
            {
                Id = YitIdHelper.NextId().ToString(),
                TargetUserId = targetUserId.Trim(),
                OperatorUserId = opId,
                OperatorUserName = opName,
                OperateTime = DateTime.Now,
                Action = action,
                FieldKey = "Password",
                FieldLabel = "密码",
                OldValue = "[已变更]",
                NewValue = "[已变更]",
                Source = source,
                ClientIp = ip,
                UserAgent = ua,
                Remark = remark
            }).ExecuteCommandAsync();
        }

        /// <summary>
        /// 深拷贝用户实体供事务前保存快照(避免同一引用被后续修改污染)。
        /// </summary>
        public static UserEntity CloneUser(UserEntity src)
        {
            if (src == null)
            {
                return null;
            }

            return JsonConvert.DeserializeObject<UserEntity>(JsonConvert.SerializeObject(src, CloneSettings), CloneSettings);
        }

        private static bool IsEffectivelyEmpty(object v)
        {
            if (v == null)
            {
                return true;
            }

            if (v is string s)
            {
                return string.IsNullOrWhiteSpace(s);
            }

            return false;
        }

        private static bool ValuesEqual(object a, object b)
        {
            if (a == null && b == null)
            {
                return true;
            }

            if (a == null || b == null)
            {
                return false;
            }

            if (a is DateTime dt1 && b is DateTime dt2)
            {
                return dt1 == dt2;
            }

            return Equals(a, b);
        }

        private static string FormatValue(string propName, object value)
        {
            if (value == null)
            {
                return string.Empty;
            }

            if (value is DateTime dt)
            {
                return dt.ToString("yyyy-MM-dd HH:mm:ss");
            }

            var s = Convert.ToString(value);
            if (string.IsNullOrEmpty(s))
            {
                return string.Empty;
            }

            if (propName.Equals(nameof(UserEntity.MobilePhone), StringComparison.OrdinalIgnoreCase) ||
                propName.Equals(nameof(UserEntity.UrgentTelePhone), StringComparison.OrdinalIgnoreCase) ||
                propName.Equals(nameof(UserEntity.TelePhone), StringComparison.OrdinalIgnoreCase))
            {
                return MaskPhone(s);
            }

            if (propName.Equals(nameof(UserEntity.CertificatesNumber), StringComparison.OrdinalIgnoreCase))
            {
                return MaskIdCard(s);
            }

            return s;
        }

        private static string MaskPhone(string s)
        {
            if (string.IsNullOrEmpty(s) || s.Length < 7)
            {
                return "[已变更]";
            }

            return s.Substring(0, 3) + "****" + s.Substring(s.Length - 4);
        }

        private static string MaskIdCard(string s)
        {
            if (string.IsNullOrEmpty(s) || s.Length < 8)
            {
                return "[已变更]";
            }

            return s.Substring(0, 4) + "********" + s.Substring(s.Length - 4);
        }

        /// <summary>
        /// 从当前请求 JWT 解析操作人,避免注入 IUserManager 与 UserRelationService 形成循环依赖。
        /// </summary>
        private static (string opId, string opName, string ip, string ua) ResolveOperatorContext()
        {
            var ctx = App.HttpContext;
            string uid = null;
            var userName = "系统";
            if (ctx?.User?.Identity?.IsAuthenticated == true)
            {
                uid = ctx.User.FindFirst(ClaimConst.CLAINM_USERID)?.Value?.Trim();
                userName = ctx.User.FindFirst(ClaimConst.CLAINM_REALNAME)?.Value?.Trim();
                if (string.IsNullOrWhiteSpace(userName))
                {
                    userName = ctx.User.FindFirst(ClaimConst.CLAINM_ACCOUNT)?.Value?.Trim();
                }

                if (string.IsNullOrWhiteSpace(userName))
                {
                    userName = "系统";
                }
            }

            string ip = null;
            string ua = null;
            if (ctx != null)
            {
                ip = ctx.Connection?.RemoteIpAddress?.ToString();
                ua = ctx.Request?.Headers["User-Agent"].ToString();
                if (ua != null && ua.Length > 500)
                {
                    ua = ua.Substring(0, 500);
                }

                try
                {
                    var client = Parser.GetDefault().Parse(ctx.Request.Headers["User-Agent"]);
                    if (!string.IsNullOrEmpty(client?.String))
                    {
                        ua = client.String.Length > 500 ? client.String.Substring(0, 500) : client.String;
                    }
                }
                catch
                {
                    // ignore
                }
            }

            return (uid, userName, ip, ua);
        }
    }
}