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