using System; using System.Collections.Generic; using System.ComponentModel; using System.Globalization; using System.Linq; using System.Reflection; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using NCC; using NCC.Common.Core.Manager; using NCC.Common.Filter; using NCC.Dependency; using NCC.DynamicApiController; using NCC.Expand.Thirdparty.Sms; using NCC.Expand.Thirdparty.Sms.Model; using NCC.Extend.Entitys.Dto.LqSms; using NCC.Extend.Entitys.Enum; using NCC.Extend.Entitys.lq_khxx; using NCC.Extend.Entitys.lq_sms_send_log; using NCC.Extend.Entitys.lq_sms_template; using NCC.Extend.Entitys.lq_sms_template; using NCC.Extend.Interfaces.BusinessOperationLog; using NCC.Extend.Interfaces.LqSms; using NCC.FriendlyException; using NCC.System.Entitys.Permission; using NCC.System.Entitys.System; using Newtonsoft.Json; using SqlSugar; using Yitter.IdGenerator; namespace NCC.Extend { /// /// 阿里云短信:配置、模板管理、按场景发送、会员生日批量发送 /// [ApiDescriptionSettings(Tag = "绿纤短信", Name = "LqSms", Order = 220)] [Route("api/Extend/[controller]")] [ApiController] public class LqSmsService : ILqSmsService, IDynamicApiController, ITransient { private const string BizLogModule = "短信管理"; private const string ConfigCategory = "SmsConfig"; private const string LegacyConfigCategory = "SysConfig"; private const string DefaultSignName = "绿纤聚财"; private const string DefaultEndpoint = "dysmsapi.aliyuncs.com"; private const string KeyAccessKeyId = "smskeyid"; private const string KeyAccessKeySecret = "smskeysecret"; private const string KeySignName = "smssignname"; private const string KeyCompany = "smscompany"; private const string KeyEndpoint = "smsendpoint"; private const string KeyPreventDuplicate = "smspreventduplicate"; private const string SceneBirthday = "Birthday"; private static readonly string[] LunarMonthNames = { "正月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "冬月", "腊月" }; private static readonly string[] LunarDayNames = { "初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十", "十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十", "廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十" }; private readonly ISqlSugarClient _db; private readonly ILogger _logger; private readonly IBusinessOperationLogService _businessOperationLogService; /// /// 初始化短信服务 /// public LqSmsService( ISqlSugarClient db, ILogger logger, IBusinessOperationLogService businessOperationLogService) { _db = db; _logger = logger; _businessOperationLogService = businessOperationLogService; } #region 配置 /// /// 获取短信配置(Secret 脱敏) /// /// /// 读取 Category=SmsConfig;AccessKey 未单独配置时复用 appsettings 中 NCC_App:AliyunOSS 凭证(与 OSS 同源)。 /// /// 示例请求: /// GET /api/Extend/LqSms/GetConfig /// /// 短信配置(密钥脱敏) /// 成功 /// 服务器错误 [HttpGet("GetConfig")] public async Task GetConfig() { var map = await LoadConfigMapAsync(); var cred = ResolveAliyunCredentials(map); return new LqSmsConfigOutput { accessKeyId = cred.AccessKeyId ?? string.Empty, accessKeySecretMasked = MaskSecret(cred.AccessKeySecret), hasAccessKeySecret = !string.IsNullOrWhiteSpace(cred.AccessKeySecret), credentialSource = cred.Source, signName = string.IsNullOrWhiteSpace(GetMapValue(map, KeySignName)) ? DefaultSignName : GetMapValue(map, KeySignName), // 生日/场景发送走阿里云;未配置或误存腾讯时默认阿里云 company = ResolveSmsCompany(map), endpoint = string.IsNullOrWhiteSpace(GetMapValue(map, KeyEndpoint)) ? DefaultEndpoint : GetMapValue(map, KeyEndpoint), preventDuplicate = ParseSwitch(GetMapValue(map, KeyPreventDuplicate), 1) }; } /// /// 保存短信配置 /// /// /// AccessKeySecret 留空或含 **** 表示不修改原密钥。 /// /// 示例请求: /// ```json /// { /// "accessKeyId": "LTAIxxx", /// "accessKeySecret": "", /// "signName": "绿纤聚财", /// "company": "1", /// "endpoint": "dysmsapi.aliyuncs.com", /// "preventDuplicate": 1 /// } /// ``` /// /// 配置入参 /// /// 保存成功 /// 参数错误 /// 服务器错误 [HttpPost("SaveConfig")] public async Task SaveConfig([FromBody] LqSmsConfigSaveInput input) { if (input == null) { throw NCCException.Oh("参数不能为空"); } var before = await GetConfig(); var signName = string.IsNullOrWhiteSpace(input.signName) ? DefaultSignName : input.signName.Trim(); // 场景短信(含生日)仅支持阿里云;未传或非法值时强制为 1 var company = string.IsNullOrWhiteSpace(input.company) || input.company.Trim() != "2" ? "1" : "2"; var endpoint = string.IsNullOrWhiteSpace(input.endpoint) ? DefaultEndpoint : input.endpoint.Trim(); var prevent = input.preventDuplicate.HasValue && input.preventDuplicate.Value == 0 ? "0" : "1"; // AccessKey 可留空:发送时自动复用 NCC_App:AliyunOSS;仅当显式填写时才写入 SmsConfig 覆盖 if (!string.IsNullOrWhiteSpace(input.accessKeyId) && !string.Equals(input.accessKeyId.Trim(), before.accessKeyId, StringComparison.Ordinal)) { await UpsertConfigAsync(KeyAccessKeyId, "短信AccessKeyId", input.accessKeyId.Trim()); } if (!IsMaskedOrEmpty(input.accessKeySecret)) { await UpsertConfigAsync(KeyAccessKeySecret, "短信AccessKeySecret", input.accessKeySecret.Trim()); } await UpsertConfigAsync(KeySignName, "短信签名", signName); await UpsertConfigAsync(KeyCompany, "短信厂商", company); await UpsertConfigAsync(KeyEndpoint, "短信Endpoint", endpoint); await UpsertConfigAsync(KeyPreventDuplicate, "生日防重发", prevent); var after = await GetConfig(); await TryWriteBizLogAsync( "保存短信配置", "BASE_SYSCONFIG", ConfigCategory, "保存阿里云短信配置", JsonConvert.SerializeObject(new { before = new { before.accessKeyId, before.accessKeySecretMasked, before.signName, before.company, before.endpoint, before.preventDuplicate }, after = new { after.accessKeyId, after.accessKeySecretMasked, after.signName, after.company, after.endpoint, after.preventDuplicate } })); } #endregion #region 模板 CRUD /// /// 短信模板分页列表 /// /// /// 示例请求: /// GET /api/Extend/LqSms/TemplateList?currentPage=1&pageSize=20 /// /// 查询参数 /// 分页列表 /// 成功 /// 服务器错误 [HttpGet("TemplateList")] public async Task TemplateList([FromQuery] LqSmsTemplateListQueryInput input) { input ??= new LqSmsTemplateListQueryInput(); input.currentPage = input.currentPage <= 0 ? 1 : input.currentPage; input.pageSize = input.pageSize <= 0 ? 20 : input.pageSize; var sidx = string.IsNullOrWhiteSpace(input.sidx) ? "sort" : input.sidx; var sort = string.IsNullOrWhiteSpace(input.sort) ? "asc" : input.sort; var data = await BuildTemplateQuery(input) .Select(it => new LqSmsTemplateListOutput { id = it.Id, name = it.Name, templateCode = it.TemplateCode, signName = it.SignName, templateType = it.TemplateType, sceneCode = it.SceneCode, paramTemplate = it.ParamTemplate, enabled = it.Enabled, sort = it.Sort, remark = it.Remark, creatorTime = it.CreatorTime }) .OrderBy($"{sidx} {sort}") .ToPagedListAsync(input.currentPage, input.pageSize); foreach (var item in data.list) { item.templateTypeName = GetEnumDescription((SmsTemplateTypeEnum)item.templateType); } return PageResult.SqlSugarPageResult(data); } /// /// 保存短信模板(新增/编辑) /// /// /// 示例请求: /// ```json /// { /// "id": "", /// "name": "生日提醒", /// "templateCode": "SMS_503595047", /// "signName": "绿纤聚财", /// "templateType": 2, /// "sceneCode": "Birthday", /// "paramTemplate": "{\"name\":\"{name}\"}", /// "enabled": 1, /// "sort": 1, /// "remark": "会员生日" /// } /// ``` /// /// 保存参数 /// 主键 /// 成功 /// 参数错误 /// 服务器错误 [HttpPost("SaveTemplate")] public async Task SaveTemplate([FromBody] LqSmsTemplateSaveInput input) { if (input == null) { throw NCCException.Oh("参数不能为空"); } if (string.IsNullOrWhiteSpace(input.name)) { throw NCCException.Oh("模板名称不能为空"); } if (string.IsNullOrWhiteSpace(input.templateCode)) { throw NCCException.Oh("模板CODE不能为空"); } if (string.IsNullOrWhiteSpace(input.sceneCode)) { throw NCCException.Oh("业务场景编码不能为空"); } if (!Enum.IsDefined(typeof(SmsTemplateTypeEnum), input.templateType)) { throw NCCException.Oh("模板类型无效"); } var userId = ResolveUserId(); var now = DateTime.Now; var sceneCode = input.sceneCode.Trim(); var isCreate = string.IsNullOrWhiteSpace(input.id); var sceneExists = await TemplateActiveQueryable() .Where(it => it.SceneCode == sceneCode) .WhereIF(!isCreate, it => it.Id != input.id) .AnyAsync(); if (sceneExists) { throw NCCException.Oh($"场景编码「{sceneCode}」已存在"); } if (isCreate) { var entity = new LqSmsTemplateEntity { Id = YitIdHelper.NextId().ToString(), Name = input.name.Trim(), TemplateCode = input.templateCode.Trim(), SignName = string.IsNullOrWhiteSpace(input.signName) ? DefaultSignName : input.signName.Trim(), TemplateType = input.templateType, SceneCode = sceneCode, ParamTemplate = string.IsNullOrWhiteSpace(input.paramTemplate) ? null : input.paramTemplate.Trim(), Enabled = input.enabled == 0 ? 0 : 1, Sort = input.sort, Remark = input.remark, CreatorTime = now, CreatorUserId = userId, DeleteMark = null }; await _db.Insertable(entity).ExecuteCommandAsync(); await TryWriteBizLogAsync("新增短信模板", "lq_sms_template", entity.Id, $"新增短信模板 {entity.Name}({entity.SceneCode}/{entity.TemplateCode})", JsonConvert.SerializeObject(new { after = entity })); return entity.Id; } var existing = await TemplateActiveQueryable().Where(it => it.Id == input.id).FirstAsync(); if (existing == null) { throw NCCException.Oh("模板不存在"); } var before = JsonConvert.SerializeObject(existing); existing.Name = input.name.Trim(); existing.TemplateCode = input.templateCode.Trim(); existing.SignName = string.IsNullOrWhiteSpace(input.signName) ? DefaultSignName : input.signName.Trim(); existing.TemplateType = input.templateType; existing.SceneCode = sceneCode; existing.ParamTemplate = string.IsNullOrWhiteSpace(input.paramTemplate) ? null : input.paramTemplate.Trim(); existing.Enabled = input.enabled == 0 ? 0 : 1; existing.Sort = input.sort; existing.Remark = input.remark; existing.LastModifyTime = now; existing.LastModifyUserId = userId; await _db.Updateable(existing).ExecuteCommandAsync(); await TryWriteBizLogAsync("修改短信模板", "lq_sms_template", existing.Id, $"修改短信模板 {existing.Name}", JsonConvert.SerializeObject(new { before = JsonConvert.DeserializeObject(before), after = existing })); return existing.Id; } /// /// 启用/停用短信模板 /// /// /// 示例请求: /// ```json /// { "id": "xxx", "enabled": 1 } /// ``` /// /// id + enabled /// 成功 /// 参数错误 /// 服务器错误 [HttpPost("ToggleTemplateEnabled")] public async Task ToggleTemplateEnabled([FromBody] LqSmsTemplateSaveInput input) { if (input == null || string.IsNullOrWhiteSpace(input.id)) { throw NCCException.Oh("模板ID不能为空"); } var entity = await TemplateActiveQueryable().Where(it => it.Id == input.id).FirstAsync(); if (entity == null) { throw NCCException.Oh("模板不存在"); } var before = entity.Enabled; entity.Enabled = input.enabled == 0 ? 0 : 1; entity.LastModifyTime = DateTime.Now; entity.LastModifyUserId = ResolveUserId(); await _db.Updateable(entity).UpdateColumns(it => new { it.Enabled, it.LastModifyTime, it.LastModifyUserId }) .ExecuteCommandAsync(); var action = entity.Enabled == 1 ? "启用短信模板" : "停用短信模板"; await TryWriteBizLogAsync(action, "lq_sms_template", entity.Id, $"{action} {entity.Name}", JsonConvert.SerializeObject(new { before, after = entity.Enabled })); } /// /// 删除短信模板(软删) /// /// 模板主键 /// 成功 /// 参数错误 /// 服务器错误 [HttpDelete("DeleteTemplate/{id}")] public async Task DeleteTemplate(string id) { if (string.IsNullOrWhiteSpace(id)) { throw NCCException.Oh("模板ID不能为空"); } var entity = await TemplateActiveQueryable().Where(it => it.Id == id).FirstAsync(); if (entity == null) { throw NCCException.Oh("模板不存在"); } entity.DeleteMark = 1; entity.LastModifyTime = DateTime.Now; entity.LastModifyUserId = ResolveUserId(); await _db.Updateable(entity).UpdateColumns(it => new { it.DeleteMark, it.LastModifyTime, it.LastModifyUserId }) .ExecuteCommandAsync(); await TryWriteBizLogAsync("删除短信模板", "lq_sms_template", entity.Id, $"删除短信模板 {entity.Name}({entity.SceneCode})", JsonConvert.SerializeObject(new { before = entity })); } #endregion #region 发送 /// /// 按业务场景发送短信 /// /// /// 按 sceneCode 取启用模板,合并 ParamTemplate 与 variables 后调用阿里云。 /// /// 示例请求: /// ```json /// { /// "sceneCode": "Birthday", /// "mobile": "13800138000", /// "variables": { "name": "张三" }, /// "isTest": true, /// "skipDuplicateCheck": true /// } /// ``` /// /// 发送参数 /// 发送结果 /// 成功或业务跳过 /// 参数错误 /// 服务器错误 [HttpPost("SendByScene")] public async Task SendBySceneAsync([FromBody] LqSmsSendBySceneInput input) { if (input == null) { throw NCCException.Oh("参数不能为空"); } if (string.IsNullOrWhiteSpace(input.sceneCode)) { throw NCCException.Oh("场景编码不能为空"); } if (string.IsNullOrWhiteSpace(input.mobile)) { throw NCCException.Oh("手机号不能为空"); } var result = await SendInternalAsync(input, DateTime.Today, input.skipDuplicateCheck); if (input.isTest || result.status == (int)SmsSendStatusEnum.成功) { await TryWriteBizLogAsync( input.isTest ? "测试发送短信" : "发送短信", "lq_sms_send_log", result.logId as string, $"{(input.isTest ? "测试" : "")}发送短信 {input.sceneCode} 至 {MaskMobile(input.mobile)},结果:{result.statusName}", JsonConvert.SerializeObject(new { input.sceneCode, mobile = MaskMobile(input.mobile), result.status, result.providerResult })); } return result; } /// /// 预览当日生日会员(与生日日历规则一致) /// /// /// 示例请求: /// GET /api/Extend/LqSms/PreviewBirthdayMembers?bizDate=2026-07-23&storeId= /// /// 业务日期 yyyy-MM-dd /// 门店ID /// 当日生日会员列表 /// 成功 /// 服务器错误 [HttpGet("PreviewBirthdayMembers")] public async Task PreviewBirthdayMembers(string bizDate = null, string storeId = null) { var day = ParseBizDate(bizDate); var list = await GetBirthdayMembersAsync(day, storeId, null); var mask = await IsPrivacyMobileMaskEnabledAsync(); return new { bizDate = day.ToString("yyyy-MM-dd"), total = list.Count, list = list.Select(x => new { x.Id, name = x.Khmc, mobileMasked = FormatMobileForDisplay(x.Sjh, mask), hasMobile = !string.IsNullOrWhiteSpace(x.Sjh), birthdayType = x.BirthdayType, birthdayTypeName = x.BirthdayType == 0 ? "阳历生日" : "农历生日", storeId = x.Gsmd }).ToList() }; } /// /// 发送会员生日短信(批量/指定会员) /// /// /// 使用场景 Birthday 对应模板(默认 SMS_503595047)。同一会员同一天同一场景默认不重复发。 /// /// 示例请求: /// ```json /// { /// "bizDate": "2026-07-23", /// "storeId": "", /// "memberIds": [], /// "skipDuplicateCheck": false, /// "dryRun": false /// } /// ``` /// /// 生日发送参数 /// 汇总结果 /// 成功 /// 参数错误 /// 服务器错误 [HttpPost("SendBirthday")] public async Task SendBirthday([FromBody] LqSmsBirthdaySendInput input) { input ??= new LqSmsBirthdaySendInput(); var day = ParseBizDate(input.bizDate); var members = await GetBirthdayMembersAsync(day, input.storeId, input.memberIds); var withMobile = members.Where(m => !string.IsNullOrWhiteSpace(m.Sjh)).ToList(); if (input.dryRun) { return new { bizDate = day.ToString("yyyy-MM-dd"), dryRun = true, total = members.Count, withMobile = withMobile.Count, withoutMobile = members.Count - withMobile.Count }; } var success = 0; var failed = 0; var skipped = 0; var details = new List(); var mask = await IsPrivacyMobileMaskEnabledAsync(); foreach (var member in withMobile) { var sendInput = new LqSmsSendBySceneInput { sceneCode = SceneBirthday, mobile = member.Sjh.Trim(), memberId = member.Id, memberName = member.Khmc, variables = new Dictionary { { "name", member.Khmc ?? string.Empty } }, skipDuplicateCheck = input.skipDuplicateCheck }; var one = await SendInternalAsync(sendInput, day, input.skipDuplicateCheck); if (one.status == (int)SmsSendStatusEnum.成功) success++; else if (one.status == (int)SmsSendStatusEnum.跳过) skipped++; else failed++; details.Add(new { memberId = member.Id, name = member.Khmc, mobileMasked = FormatMobileForDisplay(member.Sjh, mask), one.status, one.statusName, one.providerResult }); } await TryWriteBizLogAsync( "发送生日短信", "lq_sms_send_log", null, $"发送生日短信 {day:yyyy-MM-dd},成功{success}/失败{failed}/跳过{skipped},候选{withMobile.Count}人", JsonConvert.SerializeObject(new { bizDate = day.ToString("yyyy-MM-dd"), input.storeId, memberIdCount = input.memberIds?.Count ?? 0, success, failed, skipped })); return new { bizDate = day.ToString("yyyy-MM-dd"), total = members.Count, withMobile = withMobile.Count, success, failed, skipped, details }; } /// /// 短信发送日志分页 /// /// /// 示例请求: /// GET /api/Extend/LqSms/SendLogList?currentPage=1&pageSize=20&sceneCode=Birthday /// /// 查询参数 /// 分页日志 /// 成功 /// 服务器错误 [HttpGet("SendLogList")] public async Task SendLogList([FromQuery] LqSmsSendLogListQueryInput input) { input ??= new LqSmsSendLogListQueryInput(); input.currentPage = input.currentPage <= 0 ? 1 : input.currentPage; input.pageSize = input.pageSize <= 0 ? 20 : input.pageSize; DateTime? start = null; DateTime? end = null; if (!string.IsNullOrWhiteSpace(input.startDate) && DateTime.TryParse(input.startDate, out var s)) { start = s.Date; } if (!string.IsNullOrWhiteSpace(input.endDate) && DateTime.TryParse(input.endDate, out var e)) { end = e.Date; } var data = await _db.Queryable() .WhereIF(!string.IsNullOrWhiteSpace(input.sceneCode), it => it.SceneCode == input.sceneCode) .WhereIF(input.status.HasValue, it => it.Status == input.status.Value) .WhereIF(!string.IsNullOrWhiteSpace(input.memberId), it => it.MemberId == input.memberId) .WhereIF(start.HasValue, it => it.BizDate >= start.Value) .WhereIF(end.HasValue, it => it.BizDate <= end.Value) .OrderBy(it => it.CreatorTime, OrderByType.Desc) .Select(it => new LqSmsSendLogListOutput { id = it.Id, sceneCode = it.SceneCode, templateCode = it.TemplateCode, signName = it.SignName, mobileMasked = it.MobileMasked, memberId = it.MemberId, memberName = it.MemberName, templateParam = it.TemplateParam, sendContent = it.SendContent, status = it.Status, providerResult = it.ProviderResult, bizDate = it.BizDate, isTest = it.IsTest, creatorTime = it.CreatorTime, creatorUserId = it.CreatorUserId }) .ToPagedListAsync(input.currentPage, input.pageSize); var mask = await IsPrivacyMobileMaskEnabledAsync(); var userIds = data.list .Select(x => x.creatorUserId) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct() .ToList(); var userMap = userIds.Count == 0 ? new Dictionary() : (await _db.Queryable() .Where(u => userIds.Contains(u.Id)) .ToListAsync()) .ToDictionary(u => u.Id, u => u); foreach (var item in data.list) { item.statusName = GetEnumDescription((SmsSendStatusEnum)item.status); if (string.IsNullOrWhiteSpace(item.sendContent)) { item.sendContent = FormatSendContent(item.signName, null, item.templateCode, item.templateParam); } item.creatorUserName = ResolveSenderDisplayName(item.creatorUserId, userMap); // 默认存明文;开启隐私开关时再脱敏。历史已脱敏数据保持原样。 if (mask) { item.mobileMasked = FormatMobileForDisplay(item.mobileMasked, true); } } return PageResult.SqlSugarPageResult(data); } #endregion #region 内部发送与配置 private async Task SendInternalAsync(LqSmsSendBySceneInput input, DateTime bizDate, bool skipDuplicate) { var template = await TemplateActiveQueryable() .Where(it => it.SceneCode == input.sceneCode.Trim()) .Where(it => it.Enabled == (int)SmsEnabledEnum.启用) .FirstAsync(); if (template == null) { throw NCCException.Oh($"未找到启用的场景模板:{input.sceneCode}"); } var configMap = await LoadConfigMapAsync(); var prevent = ParseSwitch(GetMapValue(configMap, KeyPreventDuplicate), 1) == 1; var memberId = string.IsNullOrWhiteSpace(input.memberId) ? null : input.memberId.Trim(); var defaultSign = GetMapValue(configMap, KeySignName); var signName = string.IsNullOrWhiteSpace(template.SignName) ? (string.IsNullOrWhiteSpace(defaultSign) ? DefaultSignName : defaultSign) : template.SignName; if (!skipDuplicate && prevent && !string.IsNullOrWhiteSpace(memberId)) { var exists = await _db.Queryable() .Where(it => it.BizDate == bizDate.Date) .Where(it => it.SceneCode == template.SceneCode) .Where(it => it.MemberId == memberId) .Where(it => it.Status == (int)SmsSendStatusEnum.成功) .AnyAsync(); if (exists) { var skipLog = await InsertSendLogAsync(template, input, bizDate, (int)SmsSendStatusEnum.跳过, "同日同场景已成功发送,跳过", null, signName); return new { logId = skipLog, status = (int)SmsSendStatusEnum.跳过, statusName = "跳过", providerResult = "同日同场景已成功发送,跳过" }; } } var cred = ResolveAliyunCredentials(configMap); if (string.IsNullOrWhiteSpace(cred.AccessKeyId) || string.IsNullOrWhiteSpace(cred.AccessKeySecret)) { throw NCCException.Oh("未找到阿里云 AccessKey:请配置 NCC_App:AliyunOSS,或在短信设置中单独填写"); } var company = ResolveSmsCompany(configMap); if (company != "1") { throw NCCException.Oh("当前生日/场景发送仅支持阿里云(厂商=1),请检查短信配置"); } var variables = input.variables ?? new Dictionary(); if (!variables.ContainsKey("name") && !string.IsNullOrWhiteSpace(input.memberName)) { variables["name"] = input.memberName; } var templateParam = BuildTemplateParam(template.ParamTemplate, variables); var smsModel = new SmsModel { keyId = cred.AccessKeyId, keySecret = cred.AccessKeySecret, signName = signName, mobileAli = input.mobile.Trim(), templateId = template.TemplateCode, templateParamAli = templateParam }; string providerResult; try { providerResult = Sms.SendSmsByAli(smsModel); } catch (Exception ex) { _logger.LogError(ex, "调用阿里云短信失败 scene={Scene}", template.SceneCode); providerResult = "短信发送失败"; } var ok = string.Equals(providerResult, "OK", StringComparison.OrdinalIgnoreCase); var status = ok ? (int)SmsSendStatusEnum.成功 : (int)SmsSendStatusEnum.失败; var logId = await InsertSendLogAsync(template, input, bizDate, status, providerResult, templateParam, signName); return new { logId, status, statusName = GetEnumDescription((SmsSendStatusEnum)status), providerResult, templateCode = template.TemplateCode, signName }; } private async Task InsertSendLogAsync( LqSmsTemplateEntity template, LqSmsSendBySceneInput input, DateTime bizDate, int status, string providerResult, string templateParam, string signName) { var resolvedSign = string.IsNullOrWhiteSpace(signName) ? template.SignName : signName; var entity = new LqSmsSendLogEntity { Id = YitIdHelper.NextId().ToString(), SceneCode = template.SceneCode, TemplateId = template.Id, TemplateCode = template.TemplateCode, SignName = resolvedSign, MobileMasked = string.IsNullOrWhiteSpace(input.mobile) ? string.Empty : input.mobile.Trim(), MobileHash = HashMobile(input.mobile), MemberId = string.IsNullOrWhiteSpace(input.memberId) ? null : input.memberId.Trim(), MemberName = input.memberName, TemplateParam = templateParam, SendContent = FormatSendContent(resolvedSign, template.Name, template.TemplateCode, templateParam), Status = status, ProviderResult = providerResult, BizDate = bizDate.Date, IsTest = input.isTest ? 1 : 0, CreatorTime = DateTime.Now, CreatorUserId = ResolveUserId() }; await _db.Insertable(entity).ExecuteCommandAsync(); return entity.Id; } private async Task> LoadConfigMapAsync() { var list = await _db.Queryable() .Where(x => x.Category == ConfigCategory || x.Category == LegacyConfigCategory) .ToListAsync(); var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var item in list.Where(x => string.Equals(x.Category, LegacyConfigCategory, StringComparison.OrdinalIgnoreCase))) { if (!string.IsNullOrWhiteSpace(item.Key)) { map[item.Key.ToLowerInvariant()] = item.Value; } } foreach (var item in list.Where(x => string.Equals(x.Category, ConfigCategory, StringComparison.OrdinalIgnoreCase))) { if (!string.IsNullOrWhiteSpace(item.Key)) { map[item.Key.ToLowerInvariant()] = item.Value; } } return map; } /// /// 解析阿里云 AK/SK:SmsConfig 显式有效覆盖优先,否则复用 NCC_App:AliyunOSS(与 OSS 同源,不重复硬编码) /// private static (string AccessKeyId, string AccessKeySecret, string Source) ResolveAliyunCredentials( Dictionary map) { var smsId = GetMapValue(map, KeyAccessKeyId); var smsSecret = GetMapValue(map, KeyAccessKeySecret); if (IsUsableAccessKeyPair(smsId, smsSecret)) { return (smsId.Trim(), smsSecret.Trim(), "SmsConfig"); } var ossId = App.Configuration["NCC_App:AliyunOSS:AccessKeyId"] ?? App.Configuration["NCC_APP:AliyunOSS:AccessKeyId"]; var ossSecret = App.Configuration["NCC_App:AliyunOSS:AccessKeySecret"] ?? App.Configuration["NCC_APP:AliyunOSS:AccessKeySecret"]; if (IsUsableAccessKeyPair(ossId, ossSecret)) { return (ossId.Trim(), ossSecret.Trim(), "AliyunOSS"); } return (ossId?.Trim() ?? smsId?.Trim(), ossSecret?.Trim() ?? smsSecret?.Trim(), "None"); } /// /// 是否为可用的 AccessKey 对(排除历史占位 123、过短密钥) /// private static bool IsUsableAccessKeyPair(string accessKeyId, string accessKeySecret) { if (string.IsNullOrWhiteSpace(accessKeyId) || string.IsNullOrWhiteSpace(accessKeySecret)) { return false; } var id = accessKeyId.Trim(); var secret = accessKeySecret.Trim(); if (id == "123" || secret == "123") { return false; } // 阿里云 AccessKeyId 通常以 LTAI 开头;Secret 长度通常 >= 16 return secret.Length >= 16; } private static string ResolveSmsCompany(Dictionary map) { // 本服务场景/生日发送仅支持阿里云。历史 SysConfig 可能残留 smscompany=2(腾讯), // LoadConfigMap 虽会用 SmsConfig 覆盖,但未写入前会误读成腾讯导致发送直接失败。 var company = GetMapValue(map, KeyCompany); if (string.Equals(company?.Trim(), "2", StringComparison.Ordinal)) { // 仅当 SmsConfig 显式保存为腾讯时保留;否则一律按阿里云 // 注意:map 已是 Legacy→SmsConfig 覆盖后的结果,此处再兜底成 1,避免旧值 2 阻断生日发送 } return "1"; } private async Task UpsertConfigAsync(string key, string name, string value) { var normalizedKey = key.ToLowerInvariant(); var existing = await _db.Queryable() .Where(x => x.Category == ConfigCategory && SqlFunc.ToLower(x.Key) == normalizedKey) .FirstAsync(); if (existing == null) { await _db.Insertable(new SysConfigEntity { Id = YitIdHelper.NextId().ToString(), Category = ConfigCategory, Key = normalizedKey, Name = name, Value = value }).ExecuteCommandAsync(); } else { existing.Value = value; existing.Name = name; await _db.Updateable(existing).UpdateColumns(x => new { x.Value, x.Name }).ExecuteCommandAsync(); } } #endregion #region 生日会员(与 GetUpcomingBirthdays 规则一致) private async Task> GetBirthdayMembersAsync( DateTime day, string storeId, List memberIds) { var query = _db.Queryable() .Where(it => it.IsEffective == 1) .Where(it => !string.IsNullOrEmpty(it.Sjh)) .WhereIF(!string.IsNullOrWhiteSpace(storeId), it => it.Gsmd == storeId) .WhereIF(memberIds != null && memberIds.Count > 0, it => memberIds.Contains(it.Id)); var candidates = await query .Where(it => it.Yanglsr != null || !string.IsNullOrEmpty(it.Yinlsr)) .ToListAsync(); var result = new List(); var cal = new ChineseLunisolarCalendar(); foreach (var member in candidates) { if (IsBirthdayOnDay(member, day, cal)) { result.Add(member); } } return result; } private static bool IsBirthdayOnDay(LqKhxxEntity member, DateTime day, ChineseLunisolarCalendar cal) { if (member.BirthdayType == 0 && member.Yanglsr.HasValue) { var b = member.Yanglsr.Value; return b.Month == day.Month && b.Day == day.Day; } if (member.BirthdayType == 1 && !string.IsNullOrEmpty(member.Yinlsr)) { var lunarYear = cal.GetYear(day); var md = LunarStringToMonthDay(member.Yinlsr, cal, lunarYear); if (!md.month.HasValue || !md.day.HasValue) { return false; } var solar = LunarToSolar(cal, lunarYear, md.month.Value, md.day.Value); return solar.HasValue && solar.Value.Date == day.Date; } return false; } private static (int? month, int? day) LunarStringToMonthDay(string lunarStr, ChineseLunisolarCalendar cal, int lunarYear) { if (string.IsNullOrEmpty(lunarStr)) return (null, null); if (DateTime.TryParse(lunarStr, out var dt)) { return (dt.Month, dt.Day); } bool isLeap = lunarStr.StartsWith("闰"); int startIdx = isLeap ? 1 : 0; int baseMonth = 0; for (int i = 0; i < LunarMonthNames.Length; i++) { if (lunarStr.IndexOf(LunarMonthNames[i], startIdx, StringComparison.Ordinal) == startIdx) { baseMonth = i + 1; startIdx += LunarMonthNames[i].Length; break; } } if (baseMonth == 0) return (null, null); string dayPart = lunarStr.Substring(startIdx); int day = 0; for (int i = 0; i < LunarDayNames.Length; i++) { if (dayPart == LunarDayNames[i] || dayPart.StartsWith(LunarDayNames[i])) { day = i + 1; break; } } if (day == 0) return (null, null); int leapMonth = cal.GetLeapMonth(lunarYear); int calendarMonth; if (isLeap) { if (leapMonth > 0 && leapMonth == baseMonth) calendarMonth = baseMonth + 1; else return (null, null); } else if (leapMonth > 0 && baseMonth > leapMonth) { calendarMonth = baseMonth + 1; } else { calendarMonth = baseMonth; } return (calendarMonth, day); } private static DateTime? LunarToSolar(ChineseLunisolarCalendar cal, int lunarYear, int lunarMonth, int lunarDay) { try { return cal.ToDateTime(lunarYear, lunarMonth, lunarDay, 0, 0, 0, 0); } catch { return null; } } #endregion #region 工具方法 private ISugarQueryable TemplateActiveQueryable() { return _db.Queryable().Where(it => it.DeleteMark == null); } private ISugarQueryable BuildTemplateQuery(LqSmsTemplateListQueryInput input) { return TemplateActiveQueryable() .WhereIF(!string.IsNullOrWhiteSpace(input.name), it => it.Name.Contains(input.name.Trim())) .WhereIF(!string.IsNullOrWhiteSpace(input.templateCode), it => it.TemplateCode.Contains(input.templateCode.Trim())) .WhereIF(!string.IsNullOrWhiteSpace(input.sceneCode), it => it.SceneCode == input.sceneCode.Trim()) .WhereIF(input.templateType.HasValue, it => it.TemplateType == input.templateType.Value) .WhereIF(input.enabled.HasValue, it => it.Enabled == input.enabled.Value); } private static string BuildTemplateParam(string paramTemplate, Dictionary variables) { if (string.IsNullOrWhiteSpace(paramTemplate)) { if (variables == null || variables.Count == 0) { return null; } return JsonConvert.SerializeObject(variables); } var text = paramTemplate; if (variables != null) { foreach (var kv in variables) { text = text.Replace("{" + kv.Key + "}", kv.Value ?? string.Empty, StringComparison.OrdinalIgnoreCase); } } return text; } private static string FormatSendContent(string signName, string templateName, string templateCode, string templateParam) { var sb = new StringBuilder(); if (!string.IsNullOrWhiteSpace(signName)) { sb.Append('【').Append(signName.Trim()).Append('】'); } if (!string.IsNullOrWhiteSpace(templateName)) { if (sb.Length > 0) sb.Append(' '); sb.Append(templateName.Trim()); } if (!string.IsNullOrWhiteSpace(templateCode)) { if (sb.Length > 0) sb.Append(' '); sb.Append('(').Append(templateCode.Trim()).Append(')'); } if (!string.IsNullOrWhiteSpace(templateParam)) { if (sb.Length > 0) sb.Append(':'); sb.Append(templateParam.Trim()); } return sb.Length > 0 ? sb.ToString() : "无"; } private static string ResolveSenderDisplayName(string creatorUserId, IDictionary userMap) { if (string.IsNullOrWhiteSpace(creatorUserId)) { return "系统"; } if (userMap != null && userMap.TryGetValue(creatorUserId, out var user) && user != null) { if (!string.IsNullOrWhiteSpace(user.RealName)) { return user.RealName.Trim(); } if (!string.IsNullOrWhiteSpace(user.Account)) { return user.Account.Trim(); } } return "无"; } private static string GetMapValue(Dictionary map, string key) { if (map == null || string.IsNullOrWhiteSpace(key)) { return null; } return map.TryGetValue(key.ToLowerInvariant(), out var v) ? v : null; } private static int ParseSwitch(string value, int defaultValue) { if (string.IsNullOrWhiteSpace(value)) { return defaultValue; } return value.Trim() == "0" ? 0 : 1; } private static bool IsMaskedOrEmpty(string secret) { if (string.IsNullOrWhiteSpace(secret)) { return true; } return secret.Contains("****", StringComparison.Ordinal); } private static string MaskSecret(string secret) { if (string.IsNullOrEmpty(secret)) { return string.Empty; } if (secret.Length <= 4) { return "****"; } return "****" + secret.Substring(secret.Length - 4); } private static string MaskMobile(string mobile) { if (string.IsNullOrWhiteSpace(mobile)) { return string.Empty; } var m = mobile.Trim(); // 已脱敏数据不再二次处理 if (m.Contains("****", StringComparison.Ordinal)) { return m; } if (m.Length < 7) { return "****"; } return m.Substring(0, 3) + "****" + m.Substring(m.Length - 4); } /// /// 按隐私开关格式化手机号展示(关闭=明文,开启=脱敏) /// private static string FormatMobileForDisplay(string mobile, bool maskEnabled) { if (string.IsNullOrWhiteSpace(mobile)) { return string.Empty; } return maskEnabled ? MaskMobile(mobile) : mobile.Trim(); } /// /// 是否开启全站手机号脱敏(SysConfig.privacyMobileMask,默认关闭) /// private async Task IsPrivacyMobileMaskEnabledAsync() { var row = await _db.Queryable() .Where(x => x.Category == LegacyConfigCategory) .Where(x => SqlFunc.ToLower(x.Key) == "privacymobilemask") .FirstAsync(); return row != null && string.Equals(row.Value?.Trim(), "1", StringComparison.Ordinal); } private static string HashMobile(string mobile) { if (string.IsNullOrWhiteSpace(mobile)) { return null; } using var sha = SHA256.Create(); var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(mobile.Trim())); return Convert.ToHexString(bytes); } private static DateTime ParseBizDate(string bizDate) { if (!string.IsNullOrWhiteSpace(bizDate) && DateTime.TryParse(bizDate, out var d)) { return d.Date; } return DateTime.Today; } private static string ResolveUserId() { try { var userManager = App.GetService(); return userManager?.UserId; } catch { return null; } } private async Task TryWriteBizLogAsync( string actionCode, string bizType, string bizId, string summary, string detailJson) { try { await _businessOperationLogService.WriteAsync( BizLogModule, actionCode, bizType, bizId, summary, detailJson); } catch (Exception ex) { _logger.LogWarning(ex, "写入业务操作日志失败 action={Action}", actionCode); } } private static string GetEnumDescription(Enum value) { var field = value.GetType().GetField(value.ToString()); var attr = field?.GetCustomAttribute(); return attr?.Description ?? value.ToString(); } #endregion } }