From cc2276fe2785b6d09e3a8c7f4cbe9b8bf2207857 Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Wed, 1 Jul 2026 17:59:35 +0800 Subject: [PATCH 1/9] =?UTF-8?q?refactor(user-journal):=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E7=94=A8=E6=88=B7=E6=9C=9F=E5=88=8A=E5=85=B3=E8=81=94?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E7=A7=BB=E9=99=A4=E6=9E=9A=E4=B8=BE?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=B9=B6=E6=96=B0=E5=A2=9E=E4=BA=8C=E7=BB=B4?= =?UTF-8?q?=E7=A0=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 删除不再使用的UserJournalTypeEnum枚举 2. 将UserJournal实体的Type字段改为int类型并移除枚举依赖 3. 移除BindJournal相关接口的类型参数校验和赋值逻辑 4. 新增二维码生成和上传功能,为用户期刊绑定QrCodeUrl字段 5. 修复UserAnswerTaskController的用户ID获取逻辑 6. 调整UserJournalQrCodeOutput的字段映射,替换QrCodeContent为QrCodeUrl --- .../Helpers/QrCodeHelper.cs | 473 ++++++++++++++++++ .../Dto/Journal/BindJournalDto.cs | 10 +- .../Entity/UserJournal.cs | 12 +- .../Enum/UserJournalTypeEnum.cs | 25 - .../UserJournalService.cs | 34 +- .../WeChat/UserAnswerTaskController.cs | 3 +- 6 files changed, 501 insertions(+), 56 deletions(-) create mode 100644 QYZH.InteractiveMagazine.Common/Helpers/QrCodeHelper.cs delete mode 100644 QYZH.InteractiveMagazine.Models/Enum/UserJournalTypeEnum.cs diff --git a/QYZH.InteractiveMagazine.Common/Helpers/QrCodeHelper.cs b/QYZH.InteractiveMagazine.Common/Helpers/QrCodeHelper.cs new file mode 100644 index 0000000..f8e8ebb --- /dev/null +++ b/QYZH.InteractiveMagazine.Common/Helpers/QrCodeHelper.cs @@ -0,0 +1,473 @@ +using System.IO.Compression; +using System.Text; + +namespace QYZH.InteractiveMagazine.Common.Helpers; + +/// +/// 二维码帮助类 +/// +public static class QrCodeHelper +{ + private const int Version = 4; + private const int Size = Version * 4 + 17; + private const int DataCodewordCount = 80; + private const int ErrorCorrectionCodewordCount = 20; + private const int QuietZone = 4; + private const int Scale = 10; + private static readonly int[] AlignmentPatternPositions = [6, 26]; + + /// + /// 生成二维码PNG图片字节 + /// + /// 二维码内容 + /// PNG图片字节 + public static byte[] GeneratePng(string content) + { + var dataCodewords = CreateDataCodewords(content); + var allCodewords = dataCodewords.Concat(CreateErrorCorrectionCodewords(dataCodewords)).ToArray(); + var modules = BuildModules(allCodewords); + var imageSize = (Size + QuietZone * 2) * Scale; + var pixels = new byte[imageSize * imageSize * 4]; + + for (var i = 0; i < pixels.Length; i += 4) + { + pixels[i] = 255; + pixels[i + 1] = 255; + pixels[i + 2] = 255; + pixels[i + 3] = 255; + } + + for (var y = 0; y < Size; y++) + { + for (var x = 0; x < Size; x++) + { + if (modules[y, x]) + { + FillModule(pixels, imageSize, x + QuietZone, y + QuietZone); + } + } + } + + return EncodePng(imageSize, imageSize, pixels); + } + + private static byte[] CreateDataCodewords(string content) + { + var bytes = Encoding.UTF8.GetBytes(content); + if (bytes.Length > 78) + { + throw new InvalidOperationException("二维码内容过长"); + } + + var bits = new List(); + AppendBits(bits, 0b0100, 4); + AppendBits(bits, bytes.Length, 8); + foreach (var value in bytes) + { + AppendBits(bits, value, 8); + } + + var remaining = DataCodewordCount * 8 - bits.Count; + AppendBits(bits, 0, Math.Min(4, remaining)); + + while (bits.Count % 8 != 0) + { + bits.Add(false); + } + + var data = BitsToBytes(bits); + var pad = true; + while (data.Count < DataCodewordCount) + { + data.Add((byte)(pad ? 0xEC : 0x11)); + pad = !pad; + } + + return data.ToArray(); + } + + private static bool[,] BuildModules(byte[] codewords) + { + var modules = new bool[Size, Size]; + var reserved = new bool[Size, Size]; + + AddFinder(modules, reserved, 0, 0); + AddFinder(modules, reserved, Size - 7, 0); + AddFinder(modules, reserved, 0, Size - 7); + AddTimingPatterns(modules, reserved); + AddAlignmentPatterns(modules, reserved); + ReserveFormatAreas(reserved); + + modules[Version * 4 + 9, 8] = true; + reserved[Version * 4 + 9, 8] = true; + + AddDataModules(modules, reserved, codewords); + AddFormatBits(modules); + + return modules; + } + + private static void AddFinder(bool[,] modules, bool[,] reserved, int left, int top) + { + for (var y = -1; y <= 7; y++) + { + for (var x = -1; x <= 7; x++) + { + var px = left + x; + var py = top + y; + if (px < 0 || py < 0 || px >= Size || py >= Size) + { + continue; + } + + reserved[py, px] = true; + modules[py, px] = x >= 0 && x <= 6 && y >= 0 && y <= 6 + && (x == 0 || x == 6 || y == 0 || y == 6 || (x >= 2 && x <= 4 && y >= 2 && y <= 4)); + } + } + } + + private static void AddTimingPatterns(bool[,] modules, bool[,] reserved) + { + for (var i = 8; i < Size - 8; i++) + { + var isDark = i % 2 == 0; + modules[6, i] = isDark; + modules[i, 6] = isDark; + reserved[6, i] = true; + reserved[i, 6] = true; + } + } + + private static void AddAlignmentPatterns(bool[,] modules, bool[,] reserved) + { + foreach (var centerY in AlignmentPatternPositions) + { + foreach (var centerX in AlignmentPatternPositions) + { + if (reserved[centerY, centerX]) + { + continue; + } + + for (var y = -2; y <= 2; y++) + { + for (var x = -2; x <= 2; x++) + { + var px = centerX + x; + var py = centerY + y; + reserved[py, px] = true; + modules[py, px] = Math.Max(Math.Abs(x), Math.Abs(y)) != 1; + } + } + } + } + } + + private static void ReserveFormatAreas(bool[,] reserved) + { + for (var i = 0; i <= 8; i++) + { + reserved[8, i] = true; + reserved[i, 8] = true; + } + + for (var i = 0; i < 8; i++) + { + reserved[8, Size - 1 - i] = true; + } + + for (var i = 0; i < 7; i++) + { + reserved[Size - 1 - i, 8] = true; + } + } + + private static void AddDataModules(bool[,] modules, bool[,] reserved, byte[] codewords) + { + var bits = new List(); + foreach (var codeword in codewords) + { + AppendBits(bits, codeword, 8); + } + + var bitIndex = 0; + var upward = true; + for (var right = Size - 1; right >= 1; right -= 2) + { + if (right == 6) + { + right--; + } + + for (var i = 0; i < Size; i++) + { + var y = upward ? Size - 1 - i : i; + for (var j = 0; j < 2; j++) + { + var x = right - j; + if (reserved[y, x]) + { + continue; + } + + var bit = bitIndex < bits.Count && bits[bitIndex++]; + if ((x + y) % 2 == 0) + { + bit = !bit; + } + + modules[y, x] = bit; + } + } + + upward = !upward; + } + } + + private static void AddFormatBits(bool[,] modules) + { + var format = GetFormatBits(); + + for (var i = 0; i <= 5; i++) + { + modules[i, 8] = GetBit(format, i); + } + + modules[7, 8] = GetBit(format, 6); + modules[8, 8] = GetBit(format, 7); + modules[8, 7] = GetBit(format, 8); + + for (var i = 9; i < 15; i++) + { + modules[8, 14 - i] = GetBit(format, i); + } + + for (var i = 0; i < 8; i++) + { + modules[8, Size - 1 - i] = GetBit(format, i); + } + + for (var i = 8; i < 15; i++) + { + modules[Size - 15 + i, 8] = GetBit(format, i); + } + + modules[Size - 8, 8] = true; + } + + private static int GetFormatBits() + { + const int errorCorrectionLevelBits = 1; + const int mask = 0; + var data = (errorCorrectionLevelBits << 3) | mask; + var value = data << 10; + const int generator = 0x537; + + for (var i = 14; i >= 10; i--) + { + if (((value >> i) & 1) != 0) + { + value ^= generator << (i - 10); + } + } + + return ((data << 10) | (value & 0x3FF)) ^ 0x5412; + } + + private static byte[] CreateErrorCorrectionCodewords(byte[] data) + { + var generator = CreateGeneratorPolynomial(ErrorCorrectionCodewordCount); + var result = new byte[ErrorCorrectionCodewordCount]; + + foreach (var b in data) + { + var factor = b ^ result[0]; + Array.Copy(result, 1, result, 0, result.Length - 1); + result[^1] = 0; + + for (var i = 0; i < result.Length; i++) + { + result[i] ^= GaloisMultiply(generator[i + 1], factor); + } + } + + return result; + } + + private static byte[] CreateGeneratorPolynomial(int degree) + { + var result = new List { 1 }; + for (var i = 0; i < degree; i++) + { + var next = new byte[result.Count + 1]; + for (var j = 0; j < result.Count; j++) + { + next[j] ^= result[j]; + next[j + 1] ^= GaloisMultiply(result[j], GaloisPower(i)); + } + + result = next.ToList(); + } + + return result.ToArray(); + } + + private static byte GaloisPower(int exponent) + { + var value = 1; + for (var i = 0; i < exponent; i++) + { + value <<= 1; + if ((value & 0x100) != 0) + { + value ^= 0x11D; + } + } + + return (byte)value; + } + + private static byte GaloisMultiply(int x, int y) + { + var result = 0; + while (y != 0) + { + if ((y & 1) != 0) + { + result ^= x; + } + + x <<= 1; + if ((x & 0x100) != 0) + { + x ^= 0x11D; + } + + y >>= 1; + } + + return (byte)result; + } + + private static void AppendBits(List bits, int value, int count) + { + for (var i = count - 1; i >= 0; i--) + { + bits.Add(((value >> i) & 1) != 0); + } + } + + private static List BitsToBytes(List bits) + { + var result = new List(); + for (var i = 0; i < bits.Count; i += 8) + { + var value = 0; + for (var j = 0; j < 8; j++) + { + value = (value << 1) | (bits[i + j] ? 1 : 0); + } + + result.Add((byte)value); + } + + return result; + } + + private static bool GetBit(int value, int index) + { + return ((value >> index) & 1) != 0; + } + + private static void FillModule(byte[] pixels, int imageSize, int moduleX, int moduleY) + { + var startX = moduleX * Scale; + var startY = moduleY * Scale; + + for (var y = 0; y < Scale; y++) + { + for (var x = 0; x < Scale; x++) + { + var index = ((startY + y) * imageSize + startX + x) * 4; + pixels[index] = 0; + pixels[index + 1] = 0; + pixels[index + 2] = 0; + pixels[index + 3] = 255; + } + } + } + + private static byte[] EncodePng(int width, int height, byte[] rgba) + { + using var output = new MemoryStream(); + WriteUInt(output, 0x89504E47); + WriteUInt(output, 0x0D0A1A0A); + + using (var ihdr = new MemoryStream()) + { + WriteUInt(ihdr, (uint)width); + WriteUInt(ihdr, (uint)height); + ihdr.WriteByte(8); + ihdr.WriteByte(6); + ihdr.WriteByte(0); + ihdr.WriteByte(0); + ihdr.WriteByte(0); + WriteChunk(output, "IHDR", ihdr.ToArray()); + } + + var stride = width * 4; + using (var raw = new MemoryStream()) + { + for (var y = 0; y < height; y++) + { + raw.WriteByte(0); + raw.Write(rgba, y * stride, stride); + } + + using var compressed = new MemoryStream(); + using (var zlib = new ZLibStream(compressed, CompressionLevel.SmallestSize, true)) + { + raw.Position = 0; + raw.CopyTo(zlib); + } + + WriteChunk(output, "IDAT", compressed.ToArray()); + } + + WriteChunk(output, "IEND", []); + return output.ToArray(); + } + + private static void WriteChunk(Stream stream, string type, byte[] data) + { + WriteUInt(stream, (uint)data.Length); + var typeBytes = Encoding.ASCII.GetBytes(type); + stream.Write(typeBytes); + stream.Write(data); + WriteUInt(stream, Crc32(typeBytes.Concat(data).ToArray())); + } + + private static void WriteUInt(Stream stream, uint value) + { + stream.WriteByte((byte)(value >> 24)); + stream.WriteByte((byte)(value >> 16)); + stream.WriteByte((byte)(value >> 8)); + stream.WriteByte((byte)value); + } + + private static uint Crc32(byte[] bytes) + { + var crc = 0xFFFFFFFFu; + foreach (var b in bytes) + { + crc ^= b; + for (var i = 0; i < 8; i++) + { + crc = (crc & 1) == 1 ? (crc >> 1) ^ 0xEDB88320u : crc >> 1; + } + } + + return ~crc; + } +} diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs index 6660c26..909874d 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs @@ -17,10 +17,6 @@ public class BindJournalInput /// public long Id { get; set; } - /// - /// 关联类型: Read(已读), Favorite(收藏), Subscribe(订阅),默认 Subscribe - /// - public string Type { get; set; } = UserJournalTypeEnum.Subscribe.ToString(); } /// @@ -121,10 +117,6 @@ public class CreateUserJournalQrCodeInput /// public long JournalId { get; set; } - /// - /// 关联类型: Read, Favorite, Subscribe,默认 Subscribe - /// - public string Type { get; set; } = UserJournalTypeEnum.Subscribe.ToString(); } /// @@ -201,7 +193,7 @@ public class UserJournalQrCodeOutput /// /// 二维码内容 /// - public string QrCodeContent { get; set; } = string.Empty; + public string QrCodeUrl { get; set; } = string.Empty; /// /// 创建时间 diff --git a/QYZH.InteractiveMagazine.Models/Entity/UserJournal.cs b/QYZH.InteractiveMagazine.Models/Entity/UserJournal.cs index 611bce8..196b884 100644 --- a/QYZH.InteractiveMagazine.Models/Entity/UserJournal.cs +++ b/QYZH.InteractiveMagazine.Models/Entity/UserJournal.cs @@ -31,12 +31,18 @@ namespace QYZH.InteractiveMagazine.Models.Entity public long JournalId { get; set; } /// - /// Desc:关联类型: Read(已读), Favorite(收藏), Subscribe(订阅) - /// Default:Read + /// Desc:关联类型: 默认0 /// Nullable:False /// [SugarColumn(ColumnName = "Type")] - public UserJournalTypeEnum Type { get; set; } + public int Type { get; set; } = 0; + /// + /// Desc:二维码地址 + /// Default: + /// Nullable:True + /// + [SugarColumn(ColumnName = "QrCodeUrl")] + public string? QrCodeUrl { get; set; } } } diff --git a/QYZH.InteractiveMagazine.Models/Enum/UserJournalTypeEnum.cs b/QYZH.InteractiveMagazine.Models/Enum/UserJournalTypeEnum.cs deleted file mode 100644 index 2004c9d..0000000 --- a/QYZH.InteractiveMagazine.Models/Enum/UserJournalTypeEnum.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel; - -namespace QYZH.InteractiveMagazine.Models.Enum; - -/// -/// 用户期刊关联类型枚举 -/// -public enum UserJournalTypeEnum -{ - /// - /// 已读 - /// - [Description("已读")] - Read = 1, - /// - /// 收藏 - /// - [Description("收藏")] - Favorite = 2, - /// - /// 订阅 - /// - [Description("订阅")] - Subscribe = 3 -} diff --git a/QYZH.InteractiveMagazine.Service/UserJournalService.cs b/QYZH.InteractiveMagazine.Service/UserJournalService.cs index a03af33..d3d7847 100644 --- a/QYZH.InteractiveMagazine.Service/UserJournalService.cs +++ b/QYZH.InteractiveMagazine.Service/UserJournalService.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.Logging; +using QYZH.InteractiveMagazine.Common.Helpers; +using QYZH.InteractiveMagazine.Infrastructure.OSS; using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ; using QYZH.InteractiveMagazine.IService; using QYZH.InteractiveMagazine.Models.Common; @@ -20,6 +22,7 @@ public class UserJournalService( BaseRepository journalRepository, ILogger logger, IRabbitMQService rabbitMqService, + OssService ossService, IPetService petService) : BaseRepository, IUserJournalService { @@ -32,8 +35,6 @@ public class UserJournalService( /// public async Task BindJournalAsync(long userId, BindJournalInput input) { - logger.LogInformation("用户绑定期刊,UserId: {UserId}, JournalId: {JournalId}, Id: {Id}, Type: {Type}", - userId, input.JournalId, input.Id, input.Type); // 校验参数 if (input.JournalId <= 0|| input.Id <= 0) @@ -63,17 +64,10 @@ public class UserJournalService( throw new BusinessException("该期刊暂未发布,无法绑定", ResultCode.UNPROCESSABLE_ENTITY); } - // 防重复绑定:同一用户 + 期刊 + 实例 + 类型 - if (!Enum.TryParse(input.Type, true, out var bindType)) - { - throw new BusinessException("关联类型不正确", ResultCode.BAD_REQUEST); - } - var userJournal = await userJournalRepository.Queryable() .Where(uj => uj.Id == input.Id && uj.JournalId == input.JournalId && !uj.IsDeleted) .FirstAsync(); - // 检查是否为首次绑定期刊(用于激活宠物) if (userJournal == null) { logger.LogWarning("绑定期刊失败,二维码记录不存在,UserId: {UserId}, JournalId: {JournalId}, Id: {Id}", userId, input.JournalId, input.Id); @@ -97,7 +91,6 @@ public class UserJournalService( // 创建绑定记录 var updateCount = await userJournalRepository.Updateable() .SetColumns(uj => uj.UserId == userId) - .SetColumns(uj => uj.Type == bindType) .SetColumns(uj => uj.UpdatedBy == userId.ToString()) .SetColumns(uj => uj.UpdatedAt == DateTime.Now) .Where(uj => uj.Id == input.Id && !uj.IsDeleted && (uj.UserId == null || uj.UserId == 0)) @@ -111,7 +104,6 @@ public class UserJournalService( logger.LogInformation("用户绑定期刊成功,UserId: {UserId}, JournalId: {JournalId}, Id: {Id}", userId, input.JournalId, userJournal.Id); userJournal.UserId = userId; - userJournal.Type = bindType; userJournal.UpdatedBy = userId.ToString(); userJournal.UpdatedAt = DateTime.Now; @@ -152,11 +144,6 @@ public class UserJournalService( throw new BusinessException("期刊Id不能为空", ResultCode.BAD_REQUEST); } - if (!Enum.TryParse(input.Type, true, out var type)) - { - throw new BusinessException("关联类型不正确", ResultCode.BAD_REQUEST); - } - var journal = await journalRepository.GetByIdAsync(input.JournalId); if (journal == null || journal.IsDeleted) { @@ -172,7 +159,7 @@ public class UserJournalService( { UserId = null, JournalId = input.JournalId, - Type = type, + Type = 0, Status = (int)UserJournalStatusEnum.Active, IsDeleted = false, CreatedBy = operatorId.ToString(), @@ -181,6 +168,17 @@ public class UserJournalService( UpdatedAt = DateTime.Now }; + var qrCodeContent = BuildQrCodeContent(record.JournalId, record.Id); + var qrCodeKey = $"journal/qrcode/{record.JournalId}/{record.Id}.png"; + using var qrCodeStream = new MemoryStream(QrCodeHelper.GeneratePng(qrCodeContent)); + var uploadedKey = ossService.PutObject(qrCodeKey, qrCodeStream); + if (string.IsNullOrWhiteSpace(uploadedKey)) + { + throw new BusinessException("二维码图片上传失败,请稍后重试", ResultCode.GLOBAL_ERROR); + } + + record.QrCodeUrl = uploadedKey; + var result = await userJournalRepository.InsertAsync(record); if (!result) { @@ -322,7 +320,7 @@ public class UserJournalService( Type = record.Type.ToString(), Status = record.Status.ToString(), IsBound = record.UserId.HasValue && record.UserId.Value > 0, - QrCodeContent = BuildQrCodeContent(record.JournalId, record.Id), + QrCodeUrl = DomainHelper.OssFullUrl(record.QrCodeUrl ?? string.Empty), CreatedAt = record.CreatedAt, BoundAt = record.UserId.HasValue && record.UserId.Value > 0 ? record.UpdatedAt : null }; diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs index e80782f..62e2211 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs +++ b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs @@ -239,7 +239,8 @@ public class UserAnswerTaskController : WeChatBaseController { try { - var userId = ConstUserId;//GetCurrentUserId(); + + var userId = GetCurrentUserId(); if (userId == 0) { return BaseResponse.Fail(ResultCode.DENY, "未获取到用户信息"); From bfdfad14d845514231815ef54ca031c6fb00d0c4 Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Thu, 2 Jul 2026 08:52:41 +0800 Subject: [PATCH 2/9] =?UTF-8?q?refactor(auth):=20=E9=87=8D=E6=9E=84JWT?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A3=B0=E6=98=8E=E8=8E=B7=E5=8F=96=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 提取通用的GetClaim方法简化多声明类型查找逻辑 2. 重构JWT认证配置代码,拆分配置逻辑到单独方法 3. 优化开发环境下的认证策略,支持无认证和JWT认证自动切换 --- .../Auth/JwtHelper.cs | 9 +- .../DependencyInjectionExtensions.cs | 127 +++++++++++------- 2 files changed, 82 insertions(+), 54 deletions(-) diff --git a/QYZH.InteractiveMagazine.Infrastructure/Auth/JwtHelper.cs b/QYZH.InteractiveMagazine.Infrastructure/Auth/JwtHelper.cs index f1f7310..9cc7b28 100644 --- a/QYZH.InteractiveMagazine.Infrastructure/Auth/JwtHelper.cs +++ b/QYZH.InteractiveMagazine.Infrastructure/Auth/JwtHelper.cs @@ -119,7 +119,7 @@ public static class JwtHelper var tokenHandler = new JwtSecurityTokenHandler(); if (tokenHandler.ReadToken(token) is JwtSecurityToken jwtToken) { - var userIdClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier); + var userIdClaim = GetClaim(jwtToken, ClaimTypes.NameIdentifier, JwtRegisteredClaimNames.NameId, JwtRegisteredClaimNames.Sub); if (long.TryParse(userIdClaim?.Value, out long userId)) { return userId; @@ -157,8 +157,13 @@ public static class JwtHelper var tokenHandler = new JwtSecurityTokenHandler(); if (tokenHandler.ReadToken(token) is JwtSecurityToken jwtToken) { - return jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? string.Empty; + return GetClaim(jwtToken, ClaimTypes.Name, JwtRegisteredClaimNames.UniqueName, JwtRegisteredClaimNames.Name)?.Value ?? string.Empty; } return string.Empty; } + + private static Claim? GetClaim(JwtSecurityToken jwtToken, params string[] claimTypes) + { + return jwtToken.Claims.FirstOrDefault(c => claimTypes.Contains(c.Type)); + } } diff --git a/QYZH.InteractiveMagazine.Infrastructure/Extensions/DependencyInjectionExtensions.cs b/QYZH.InteractiveMagazine.Infrastructure/Extensions/DependencyInjectionExtensions.cs index ebfc4e7..c0e8c3b 100644 --- a/QYZH.InteractiveMagazine.Infrastructure/Extensions/DependencyInjectionExtensions.cs +++ b/QYZH.InteractiveMagazine.Infrastructure/Extensions/DependencyInjectionExtensions.cs @@ -21,6 +21,9 @@ namespace QYZH.InteractiveMagazine.Infrastructure.Extensions; /// public static class DependencyInjectionExtensions { + private const string NoAuthScheme = "NoAuth"; + private const string DevelopmentAuthScheme = "DevelopmentSmartAuth"; + /// /// Registers infrastructure services. /// @@ -34,70 +37,90 @@ public static class DependencyInjectionExtensions private static void AddJwtAuthentication(IServiceCollection services, IConfiguration configuration, IWebHostEnvironment? environment = null) { + var jwtSettings = configuration.GetSection("JwtSettings").Get()!; + services.AddSingleton(jwtSettings); + if (environment?.IsDevelopment() == true) { - services.AddAuthentication("NoAuth") - .AddScheme("NoAuth", options => { }); + services.AddAuthentication(options => + { + options.DefaultScheme = DevelopmentAuthScheme; + options.DefaultChallengeScheme = DevelopmentAuthScheme; + }) + .AddPolicyScheme(DevelopmentAuthScheme, null, options => + { + options.ForwardDefaultSelector = context => + { + var authHeader = context.Request.Headers.Authorization.FirstOrDefault(); + return !string.IsNullOrWhiteSpace(authHeader) && + authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? JwtBearerDefaults.AuthenticationScheme + : NoAuthScheme; + }; + }) + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => ConfigureJwtBearer(options, jwtSettings)) + .AddScheme(NoAuthScheme, options => { }); return; } - var jwtSettings = configuration.GetSection("JwtSettings").Get()!; - - services.AddSingleton(jwtSettings); - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => + .AddJwtBearer(options => ConfigureJwtBearer(options, jwtSettings)); + } + + private static void ConfigureJwtBearer(JwtBearerOptions options, JwtSettings jwtSettings) + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtSettings.Issuer, + ValidateAudience = true, + ValidAudience = jwtSettings.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SecretKey!)), + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = async context => { - options.TokenValidationParameters = new TokenValidationParameters + var currentToken = GetBearerToken(context); + if (string.IsNullOrEmpty(currentToken)) { - ValidateIssuer = true, - ValidIssuer = jwtSettings.Issuer, - ValidateAudience = true, - ValidAudience = jwtSettings.Audience, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SecretKey!)), - ValidateLifetime = true, - ClockSkew = TimeSpan.Zero - }; + context.Fail("Invalid token"); + return; + } - options.Events = new JwtBearerEvents + var userId = context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) { - OnTokenValidated = async context => + context.Fail("Invalid token"); + return; + } + + var wxUserId = context.Principal?.FindFirst(JwtHelper.WxUserIdClaimType)?.Value; + if (string.IsNullOrEmpty(wxUserId)) + { + var adminToken = await RedisHelper.GetAsync(JwtHelper.BuildAdminTokenKey(userId)); + if (adminToken == currentToken) { - var currentToken = GetBearerToken(context); - if (string.IsNullOrEmpty(currentToken)) - { - context.Fail("Invalid token"); - return; - } - - var userId = context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) - { - context.Fail("Invalid token"); - return; - } - - var adminToken = await RedisHelper.GetAsync(JwtHelper.BuildAdminTokenKey(userId)); - var wxUserId = context.Principal?.FindFirst(JwtHelper.WxUserIdClaimType)?.Value; - if (string.IsNullOrEmpty(wxUserId) && !string.IsNullOrEmpty(adminToken)) - { - return; - } - - if (!string.IsNullOrEmpty(wxUserId)) - { - var wechatToken = await RedisHelper.GetAsync(JwtHelper.BuildWeChatTokenKey(wxUserId, userId)); - if (wechatToken == currentToken) - { - return; - } - } - - context.Fail("Token expired, please login again"); + return; } - }; - }); + + context.Fail("Token expired, please login again"); + return; + } + + var wechatToken = await RedisHelper.GetAsync(JwtHelper.BuildWeChatTokenKey(wxUserId, userId)); + if (wechatToken == currentToken) + { + return; + } + + context.Fail("Token expired, please login again"); + } + }; } private static string? GetBearerToken(TokenValidatedContext context) From 5700da58d966bc383a5b033aef8bd8e37d6a726e Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Thu, 2 Jul 2026 09:46:34 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=9C=9F?= =?UTF-8?q?=E5=88=8A=E4=BA=8C=E7=BB=B4=E7=A0=81=E6=89=B9=E9=87=8F=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=9B=B8=E5=85=B3=E9=85=8D?= =?UTF-8?q?=E5=A5=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增UserJournalQrCodeGenerateConsumer消费者处理二维码生成队列任务 2. 新增用户期刊状态枚举的生成中、失败状态 3. 新增批量生成二维码服务方法和相关DTO 4. 优化SqlSugar自动填充创建/更新人字段逻辑 5. 调整接口参数从操作人ID改为操作人名称 6. 新增获取未绑定二维码的API接口和服务方法 --- Directory.Build.props | 5 + .../IUserJournalService.cs | 23 +++- .../Dto/Journal/BindJournalDto.cs | 56 ++++++++ .../Enum/UserJournalStatusEnum.cs | 14 +- .../Core/SqlSugarExtension.cs | 50 ++++++- .../UserJournalService.cs | 113 ++++++++++++++- .../UserJournalQrCodeController.cs | 20 ++- .../UserJournalQrCodeGenerateConsumer.cs | 129 ++++++++++++++++++ .../Program.cs | 1 + 9 files changed, 392 insertions(+), 19 deletions(-) create mode 100644 Directory.Build.props create mode 100644 QYZH.InteractiveMagazine.WorkService/Consumers/UserJournalQrCodeGenerateConsumer.cs diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..97f6331 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,5 @@ + + + $(NoWarn);CS0105;CS0108;CS0168;CS0169;CS0618;CS1570;CS1572;CS1573;CS1591;CS8600;CS8601;CS8602;CS8603;CS8604;CS8618;CS8625;CS8629;CS8634;CS8714;CS9113 + + diff --git a/QYZH.InteractiveMagazine.IService/IUserJournalService.cs b/QYZH.InteractiveMagazine.IService/IUserJournalService.cs index 38102c6..0528b2f 100644 --- a/QYZH.InteractiveMagazine.IService/IUserJournalService.cs +++ b/QYZH.InteractiveMagazine.IService/IUserJournalService.cs @@ -35,9 +35,17 @@ public interface IUserJournalService : IBaseService /// 生成期刊二维码记录 /// /// 生成输入 - /// 操作人Id + /// 操作人名称 /// 二维码记录 - Task CreateQrCodeAsync(CreateUserJournalQrCodeInput input, long operatorId); + Task CreateQrCodeAsync(CreateUserJournalQrCodeInput input, string operatorName); + + /// + /// 批量提交期刊二维码生成任务 + /// + /// 生成输入 + /// 操作人名称 + /// 提交结果 + Task CreateQrCodesAsync(CreateUserJournalQrCodeInput input, string operatorName); /// /// 分页查询期刊二维码记录 @@ -53,11 +61,18 @@ public interface IUserJournalService : IBaseService /// 二维码记录 Task GetQrCodeDetailAsync(long id); + /// + /// 根据期刊Id获取未绑定用户的二维码列表 + /// + /// 期刊Id + /// 未绑定二维码列表 + Task> GetUnboundQrCodesByJournalIdAsync(long journalId); + /// /// 删除未绑定的期刊二维码记录 /// /// 删除输入 - /// 操作人Id + /// 操作人名称 /// 是否成功 - Task DeleteQrCodeAsync(DeleteUserJournalQrCodeInput input, long operatorId); + Task DeleteQrCodeAsync(DeleteUserJournalQrCodeInput input, string operatorName); } diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs index 909874d..0907687 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs @@ -117,11 +117,67 @@ public class CreateUserJournalQrCodeInput /// public long JournalId { get; set; } + /// + /// 生成数量 + /// + public int Count { get; set; } = 1; +} + +/// +/// 生成期刊二维码提交结果 +/// +public class CreateUserJournalQrCodeOutput +{ + /// + /// 期刊Id + /// + public long JournalId { get; set; } + + /// + /// 请求生成数量 + /// + public int RequestedCount { get; set; } + + /// + /// 已提交生成数量 + /// + public int AcceptedCount { get; set; } + + /// + /// 二维码记录Id列表 + /// + public List RecordIds { get; set; } = []; + + /// + /// 是否后台生成 + /// + public bool IsAsync { get; set; } + + /// + /// 提示信息 + /// + public string Message { get; set; } = string.Empty; } /// /// 期刊二维码查询输入DTO /// +public class GenerateUserJournalQrCodeMessage +{ + /// + /// 二维码记录Id列表 + /// + public List RecordIds { get; set; } = []; + + /// + /// 操作人名称 + /// + public string OperatorName { get; set; } = string.Empty; +} + +/// +/// 鏈熷垔浜岀淮鐮佹煡璇㈣緭鍏TO +/// public class UserJournalQrCodeQueryInput : PageQueryModel { /// diff --git a/QYZH.InteractiveMagazine.Models/Enum/UserJournalStatusEnum.cs b/QYZH.InteractiveMagazine.Models/Enum/UserJournalStatusEnum.cs index a7c77ea..56035a0 100644 --- a/QYZH.InteractiveMagazine.Models/Enum/UserJournalStatusEnum.cs +++ b/QYZH.InteractiveMagazine.Models/Enum/UserJournalStatusEnum.cs @@ -17,5 +17,17 @@ public enum UserJournalStatusEnum /// 正常 /// [Description("正常")] - Active = 1 + Active = 1, + + /// + /// 生成中 + /// + [Description("生成中")] + Generating = 2, + + /// + /// 生成失败 + /// + [Description("生成失败")] + Failed = 3 } diff --git a/QYZH.InteractiveMagazine.Repository/Core/SqlSugarExtension.cs b/QYZH.InteractiveMagazine.Repository/Core/SqlSugarExtension.cs index 47defe5..7e94779 100644 --- a/QYZH.InteractiveMagazine.Repository/Core/SqlSugarExtension.cs +++ b/QYZH.InteractiveMagazine.Repository/Core/SqlSugarExtension.cs @@ -1,7 +1,10 @@ using QYZH.InteractiveMagazine.Models.Entity; +using Microsoft.AspNetCore.Http; +using QYZH.InteractiveMagazine.Infrastructure.Context; using SqlSugar; using System.Linq.Expressions; using System.Reflection; +using System.Security.Claims; using Yitter.IdGenerator; @@ -117,14 +120,18 @@ namespace QYZH.InteractiveMagazine.Repository.Core db.Aop.DataExecuting = (oldValue, entityInfo) => { var entityValue = entityInfo.EntityColumnInfo.PropertyInfo.GetValue(entityInfo.EntityValue)?.ToString(); - var currnetUserName = ""; + var currentUserName = GetCurrentUserName(); /*** inset生效 ***/ if (entityInfo.OperationType == DataFilterType.InsertByObject) { if (entityInfo.PropertyName == "CreatedAt" && (string.IsNullOrWhiteSpace(entityValue) || entityValue == DateTime.MinValue.ToString())) entityInfo.SetValue(DateTime.Now);//修改CreateTime字段 - else if (entityInfo.PropertyName == "CreatedBy" && (string.IsNullOrWhiteSpace(entityValue))) - entityInfo.SetValue(currnetUserName);//修改创建人字段 + else if (entityInfo.PropertyName == "CreatedBy" && ShouldSetOperatorName(entityValue, currentUserName)) + entityInfo.SetValue(currentUserName);//修改创建人字段 + else if (entityInfo.PropertyName == "UpdatedAt" && (string.IsNullOrWhiteSpace(entityValue) || entityValue == DateTime.MinValue.ToString())) + entityInfo.SetValue(DateTime.Now);//修改UpdatedTime字段 + else if (entityInfo.PropertyName == "UpdatedBy" && ShouldSetOperatorName(entityValue, currentUserName)) + entityInfo.SetValue(currentUserName);//修改更新人字段 else if (entityInfo.PropertyName == "IsDeleted" && string.IsNullOrWhiteSpace(entityValue)) entityInfo.SetValue("0");//修改CreateTime字段 } @@ -134,8 +141,8 @@ namespace QYZH.InteractiveMagazine.Repository.Core { if (entityInfo.PropertyName == "UpdatedAt" && (string.IsNullOrWhiteSpace(entityValue) || entityValue == DateTime.MinValue.ToString())) entityInfo.SetValue(DateTime.Now);//修改UpdatedTime字段 - else if (entityInfo.PropertyName == "UpdatedBy" && (string.IsNullOrWhiteSpace(entityValue) || entityValue == "0")) - entityInfo.SetValue(currnetUserName);//修改更新人字段 + else if (entityInfo.PropertyName == "UpdatedBy" && ShouldSetOperatorName(entityValue, currentUserName)) + entityInfo.SetValue(currentUserName);//修改更新人字段 } }; return db; @@ -143,6 +150,39 @@ namespace QYZH.InteractiveMagazine.Repository.Core } + private static string GetCurrentUserName() + { + try + { + var httpContextAccessor = ServiceContext.ServiceProvider?.GetService(typeof(IHttpContextAccessor)) as IHttpContextAccessor; + var user = httpContextAccessor?.HttpContext?.User; + var userName = user?.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value + ?? user?.Identity?.Name; + + return string.IsNullOrWhiteSpace(userName) ? "System" : userName; + } + catch + { + return "System"; + } + } + + private static bool ShouldSetOperatorName(string? entityValue, string currentUserName) + { + if (string.IsNullOrWhiteSpace(entityValue) || entityValue == "0") + { + return true; + } + + if (currentUserName != "System" && long.TryParse(entityValue, out _)) + { + return true; + } + + return false; + } + + /// /// 把一个字符串转成驼峰规则的字符串 /// diff --git a/QYZH.InteractiveMagazine.Service/UserJournalService.cs b/QYZH.InteractiveMagazine.Service/UserJournalService.cs index d3d7847..e5af33c 100644 --- a/QYZH.InteractiveMagazine.Service/UserJournalService.cs +++ b/QYZH.InteractiveMagazine.Service/UserJournalService.cs @@ -29,6 +29,9 @@ public class UserJournalService( private const string JournalExchange = "ex.journal"; private const string BindJournalQueue = "mq.journal.bindUser"; private const string BindJournalRoutingKey = "rk.journal.bindUser"; + private const string QrCodeGenerateQueue = "mq.journal.qrcode.generate"; + private const string QrCodeGenerateRoutingKey = "rk.journal.qrcode.generate"; + private const int MaxBatchQrCodeCount = 500; /// /// 用户绑定期刊(扫码绑定) @@ -137,7 +140,7 @@ public class UserJournalService( /// /// 生成期刊二维码记录 /// - public async Task CreateQrCodeAsync(CreateUserJournalQrCodeInput input, long operatorId) + public async Task CreateQrCodeAsync(CreateUserJournalQrCodeInput input, string operatorName) { if (input.JournalId <= 0) { @@ -162,9 +165,9 @@ public class UserJournalService( Type = 0, Status = (int)UserJournalStatusEnum.Active, IsDeleted = false, - CreatedBy = operatorId.ToString(), + CreatedBy = operatorName, CreatedAt = DateTime.Now, - UpdatedBy = operatorId.ToString(), + UpdatedBy = operatorName, UpdatedAt = DateTime.Now }; @@ -191,6 +194,88 @@ public class UserJournalService( /// /// 分页查询期刊二维码记录 /// + public async Task CreateQrCodesAsync(CreateUserJournalQrCodeInput input, string operatorName) + { + if (input.JournalId <= 0) + { + throw new BusinessException("期刊Id不能为空", ResultCode.BAD_REQUEST); + } + + if (input.Count <= 0 || input.Count > MaxBatchQrCodeCount) + { + throw new BusinessException($"生成数量必须在1-{MaxBatchQrCodeCount}之间", ResultCode.BAD_REQUEST); + } + + var journal = await journalRepository.GetByIdAsync(input.JournalId); + if (journal == null || journal.IsDeleted) + { + throw new BusinessException("期刊不存在", ResultCode.NOT_FOUND); + } + + if (journal.Status != (int)JournalStatusEnum.Published) + { + throw new BusinessException("该期刊暂未发布,无法生成二维码", ResultCode.UNPROCESSABLE_ENTITY); + } + + var now = DateTime.Now; + var records = Enumerable.Range(0, input.Count) + .Select(_ => new UserJournal + { + UserId = null, + JournalId = input.JournalId, + Type = 0, + Status = (int)UserJournalStatusEnum.Generating, + IsDeleted = false, + CreatedBy = operatorName, + CreatedAt = now, + UpdatedBy = operatorName, + UpdatedAt = now + }) + .ToList(); + + var insertCount = await userJournalRepository.Context.Insertable(records).ExecuteCommandAsync(); + if (insertCount <= 0) + { + throw new BusinessException("提交二维码生成任务失败,请稍后重试", ResultCode.GLOBAL_ERROR); + } + + var recordIds = records.Select(r => r.Id).ToList(); + var messageSent = await rabbitMqService.SendAsync(new RabbitMQSendParam + { + Exchange = JournalExchange, + Queue = QrCodeGenerateQueue, + RoutingKey = QrCodeGenerateRoutingKey, + Data = new GenerateUserJournalQrCodeMessage + { + RecordIds = recordIds, + OperatorName = operatorName + } + }); + + if (!messageSent) + { + await userJournalRepository.Updateable() + .SetColumns(uj => uj.Status == (int)UserJournalStatusEnum.Failed) + .SetColumns(uj => uj.UpdatedBy == operatorName) + .SetColumns(uj => uj.UpdatedAt == DateTime.Now) + .Where(uj => recordIds.Contains(uj.Id) && !uj.IsDeleted && uj.Status == (int)UserJournalStatusEnum.Generating) + .ExecuteCommandAsync(); + + logger.LogError("发送期刊二维码生成消息失败,RecordIds: {RecordIds}", string.Join(",", recordIds)); + throw new BusinessException("二维码生成任务提交失败,请稍后重试", ResultCode.GLOBAL_ERROR); + } + + return new CreateUserJournalQrCodeOutput + { + JournalId = input.JournalId, + RequestedCount = input.Count, + AcceptedCount = insertCount, + RecordIds = recordIds, + IsAsync = true, + Message = "二维码生成任务已提交,请稍后查询未绑定二维码列表" + }; + } + public async Task> GetQrCodePageListAsync(UserJournalQrCodeQueryInput input) { if (input.PageIndex <= 0) @@ -237,7 +322,7 @@ public class UserJournalService( /// /// 删除未绑定的期刊二维码记录 /// - public async Task DeleteQrCodeAsync(DeleteUserJournalQrCodeInput input, long operatorId) + public async Task DeleteQrCodeAsync(DeleteUserJournalQrCodeInput input, string operatorName) { if (input.Ids == null || input.Ids.Count == 0) { @@ -262,7 +347,7 @@ public class UserJournalService( var updateCount = await userJournalRepository.Updateable() .SetColumns(uj => uj.IsDeleted == true) .SetColumns(uj => uj.Status == (int)UserJournalStatusEnum.Inactive) - .SetColumns(uj => uj.UpdatedBy == operatorId.ToString()) + .SetColumns(uj => uj.UpdatedBy == operatorName) .SetColumns(uj => uj.UpdatedAt == DateTime.Now) .Where(uj => ids.Contains(uj.Id) && !uj.IsDeleted && (uj.UserId == null || uj.UserId == 0)) .ExecuteCommandAsync(); @@ -270,6 +355,24 @@ public class UserJournalService( return updateCount == ids.Count; } + public async Task> GetUnboundQrCodesByJournalIdAsync(long journalId) + { + if (journalId <= 0) + { + throw new BusinessException("期刊Id不能为空", ResultCode.BAD_REQUEST); + } + + var records = await userJournalRepository.Queryable() + .Where(uj => uj.JournalId == journalId && !uj.IsDeleted) + .Where(uj => uj.UserId == null || uj.UserId == 0) + .Where(uj => uj.Status == (int)UserJournalStatusEnum.Active) + .Where(uj => !string.IsNullOrEmpty(uj.QrCodeUrl)) + .OrderByDescending(uj => uj.CreatedAt) + .ToListAsync(); + + return await BuildQrCodeOutputsAsync(records); + } + private async Task> BuildQrCodeOutputsAsync(List records) { if (records.Count == 0) diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/UserJournalQrCodeController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/UserJournalQrCodeController.cs index 36591f3..9115b76 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/UserJournalQrCodeController.cs +++ b/QYZH.InteractiveMagazine.WebApi/Controllers/UserJournalQrCodeController.cs @@ -19,10 +19,22 @@ public class UserJournalQrCodeController(IUserJournalService userJournalService) /// 生成输入 /// 二维码记录 [HttpPost("add")] - public async Task> AddAsync([FromBody] CreateUserJournalQrCodeInput input) + public async Task> AddAsync([FromBody] CreateUserJournalQrCodeInput input) { - var result = await userJournalService.CreateQrCodeAsync(input, GetCurrentUserId() ?? 0); - return BaseResponse.Success(result); + var result = await userJournalService.CreateQrCodesAsync(input, GetCurrentUserName() ?? "System"); + return BaseResponse.Success(result); + } + + /// + /// 根据期刊Id获取未绑定用户的二维码地址 + /// + /// 期刊Id + /// 未绑定二维码列表 + [HttpGet("unbound/{journalId:long}")] + public async Task>> GetUnboundByJournalIdAsync(long journalId) + { + var result = await userJournalService.GetUnboundQrCodesByJournalIdAsync(journalId); + return BaseResponse>.Success(result); } /// @@ -57,7 +69,7 @@ public class UserJournalQrCodeController(IUserJournalService userJournalService) [HttpPost("delete")] public async Task> DeleteAsync([FromBody] DeleteUserJournalQrCodeInput input) { - var result = await userJournalService.DeleteQrCodeAsync(input, GetCurrentUserId() ?? 0); + var result = await userJournalService.DeleteQrCodeAsync(input, GetCurrentUserName() ?? "System"); return BaseResponse.Success(result); } } diff --git a/QYZH.InteractiveMagazine.WorkService/Consumers/UserJournalQrCodeGenerateConsumer.cs b/QYZH.InteractiveMagazine.WorkService/Consumers/UserJournalQrCodeGenerateConsumer.cs new file mode 100644 index 0000000..02d20eb --- /dev/null +++ b/QYZH.InteractiveMagazine.WorkService/Consumers/UserJournalQrCodeGenerateConsumer.cs @@ -0,0 +1,129 @@ +using QYZH.InteractiveMagazine.Common.Helpers; +using QYZH.InteractiveMagazine.Infrastructure.OSS; +using QYZH.InteractiveMagazine.Models.Common; +using QYZH.InteractiveMagazine.Models.Dto; +using QYZH.InteractiveMagazine.Models.Entity; +using QYZH.InteractiveMagazine.Models.Enum; +using SqlSugar; +using System.Text; +using System.Text.Json; + +namespace QYZH.InteractiveMagazine.WorkService.Consumers; + +/// +/// 期刊二维码生成消费者 +/// +public class UserJournalQrCodeGenerateConsumer( + ILogger logger, + IServiceScopeFactory scopeFactory, + OssService ossService) : IQueueConsumer +{ + public string Exchange => "ex.journal"; + + public string QueueName => "mq.journal.qrcode.generate"; + + public string RoutingKey => "rk.journal.qrcode.generate"; + + public async Task HandleAsync(byte[] message, CancellationToken cancellationToken = default) + { + var content = Encoding.UTF8.GetString(message); + logger.LogInformation("收到期刊二维码生成消息: {Message}", content); + + var data = JsonSerializer.Deserialize(content, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? throw new InvalidOperationException("期刊二维码生成消息为空"); + + if (data.RecordIds.Count == 0) + { + logger.LogWarning("期刊二维码生成消息缺少记录Id"); + return; + } + + using var scope = scopeFactory.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + var operatorName = string.IsNullOrWhiteSpace(data.OperatorName) ? "System" : data.OperatorName; + + foreach (var recordId in data.RecordIds.Distinct()) + { + await GenerateQrCodeImageAsync(client, recordId, operatorName, cancellationToken); + } + } + + public async Task OnErrorAsync(byte[] message, Exception exception) + { + logger.LogError(exception, "处理期刊二维码生成消息失败: {Message}", Encoding.UTF8.GetString(message)); + + try + { + var data = JsonSerializer.Deserialize(Encoding.UTF8.GetString(message), new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (data?.RecordIds.Count > 0) + { + using var scope = scopeFactory.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + var operatorName = string.IsNullOrWhiteSpace(data.OperatorName) ? "System" : data.OperatorName; + await client.Updateable() + .SetColumns(uj => uj.Status == (int)UserJournalStatusEnum.Failed) + .SetColumns(uj => uj.UpdatedBy == operatorName) + .SetColumns(uj => uj.UpdatedAt == DateTime.Now) + .Where(uj => data.RecordIds.Contains(uj.Id) && !uj.IsDeleted && uj.Status == (int)UserJournalStatusEnum.Generating) + .ExecuteCommandAsync(); + } + } + catch (Exception ex) + { + logger.LogError(ex, "标记期刊二维码生成失败状态异常"); + } + } + + private async Task GenerateQrCodeImageAsync(ISqlSugarClient client, long recordId, string operatorName, CancellationToken cancellationToken) + { + try + { + var record = await client.Queryable() + .Where(uj => uj.Id == recordId && !uj.IsDeleted) + .FirstAsync(cancellationToken); + + if (record == null || record.Status != (int)UserJournalStatusEnum.Generating) + { + return; + } + + var qrCodeContent = BuildQrCodeContent(record.JournalId, record.Id); + var qrCodeKey = $"journal/qrcode/{record.JournalId}/{record.Id}.png"; + using var qrCodeStream = new MemoryStream(QrCodeHelper.GeneratePng(qrCodeContent)); + var uploadedKey = ossService.PutObject(qrCodeKey, qrCodeStream); + if (string.IsNullOrWhiteSpace(uploadedKey)) + { + throw new BusinessException("二维码图片上传失败", ResultCode.GLOBAL_ERROR); + } + + await client.Updateable() + .SetColumns(uj => uj.QrCodeUrl == uploadedKey) + .SetColumns(uj => uj.Status == (int)UserJournalStatusEnum.Active) + .SetColumns(uj => uj.UpdatedBy == operatorName) + .SetColumns(uj => uj.UpdatedAt == DateTime.Now) + .Where(uj => uj.Id == recordId && !uj.IsDeleted && uj.Status == (int)UserJournalStatusEnum.Generating) + .ExecuteCommandAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "生成期刊二维码失败,RecordId: {RecordId}", recordId); + await client.Updateable() + .SetColumns(uj => uj.Status == (int)UserJournalStatusEnum.Failed) + .SetColumns(uj => uj.UpdatedBy == operatorName) + .SetColumns(uj => uj.UpdatedAt == DateTime.Now) + .Where(uj => uj.Id == recordId && !uj.IsDeleted && uj.Status == (int)UserJournalStatusEnum.Generating) + .ExecuteCommandAsync(cancellationToken); + } + } + + private static string BuildQrCodeContent(long journalId, long id) + { + return JsonSerializer.Serialize(new { JournalId = journalId, Id = id }); + } +} diff --git a/QYZH.InteractiveMagazine.WorkService/Program.cs b/QYZH.InteractiveMagazine.WorkService/Program.cs index 16ccff2..3df9ab6 100644 --- a/QYZH.InteractiveMagazine.WorkService/Program.cs +++ b/QYZH.InteractiveMagazine.WorkService/Program.cs @@ -77,6 +77,7 @@ builder.Services.AddRabbitMQ(builder.Configuration); // 注册队列消费者(新增消费者只需实现 IQueueConsumer 并在此注册) builder.Services.AddScoped(); +builder.Services.AddScoped(); // 注册消费者后台服务 builder.Services.AddHostedService(); From 72c7a38f1d3b872b3105f2576242c0217f827fc7 Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Thu, 2 Jul 2026 11:25:24 +0800 Subject: [PATCH 4/9] =?UTF-8?q?refactor(UserJournal):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=9C=9F=E5=88=8A=E7=BB=91=E5=AE=9A=E9=80=BB=E8=BE=91=E5=B9=B6?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 重构用户查询逻辑,增加未删除过滤 2. 将枚举常量提取复用,统一状态判断 3. 新增数据库事务包裹绑定操作 4. 增加重复绑定同一期刊的校验逻辑 5. 统一绑定时间变量复用 6. 完善更新条件,增加期刊ID和状态校验 --- .../UserJournalService.cs | 61 +++++++++++++------ ...YZH.InteractiveMagazine.WorkService.csproj | 6 ++ .../dotnet-tools.json | 13 ++++ 3 files changed, 62 insertions(+), 18 deletions(-) create mode 100644 QYZH.InteractiveMagazine.WorkService/dotnet-tools.json diff --git a/QYZH.InteractiveMagazine.Service/UserJournalService.cs b/QYZH.InteractiveMagazine.Service/UserJournalService.cs index e5af33c..74494e8 100644 --- a/QYZH.InteractiveMagazine.Service/UserJournalService.cs +++ b/QYZH.InteractiveMagazine.Service/UserJournalService.cs @@ -45,7 +45,12 @@ public class UserJournalService( throw new BusinessException("参数错误,未获取到期刊", ResultCode.BAD_REQUEST); } // 校验用户是否存在 - var user = await usersRepository.GetByIdAsync(userId); + var activeStatus = (int)UserJournalStatusEnum.Active; + var boundAt = DateTime.Now; + + var user = await usersRepository.Queryable() + .Where(u => u.Id == userId && !u.IsDeleted) + .FirstAsync(); if (user == null || user.IsDeleted) { logger.LogWarning("绑定期刊失败,用户不存在,UserId: {UserId}", userId); @@ -77,7 +82,7 @@ public class UserJournalService( throw new BusinessException("二维码不存在或已失效", ResultCode.NOT_FOUND); } - if (userJournal.Status != (int)UserJournalStatusEnum.Active) + if (userJournal.Status != activeStatus) { throw new BusinessException("二维码已失效", ResultCode.UNPROCESSABLE_ENTITY); } @@ -88,27 +93,47 @@ public class UserJournalService( throw new BusinessException("该期刊已被绑定", ResultCode.BAD_REQUEST); } - var isFirstBind = !userJournalRepository.Context.Queryable() - .Any(uj => uj.UserId == userId); - - // 创建绑定记录 - var updateCount = await userJournalRepository.Updateable() - .SetColumns(uj => uj.UserId == userId) - .SetColumns(uj => uj.UpdatedBy == userId.ToString()) - .SetColumns(uj => uj.UpdatedAt == DateTime.Now) - .Where(uj => uj.Id == input.Id && !uj.IsDeleted && (uj.UserId == null || uj.UserId == 0)) - .ExecuteCommandAsync(); - - if (updateCount <= 0) + var isFirstBind = false; + await userJournalRepository.UseTranAsync(async () => { - logger.LogError("绑定期刊失败,写入数据库失败,UserId: {UserId}, JournalId: {JournalId}", userId, input.JournalId); - throw new BusinessException("绑定期刊失败,请稍后重试", ResultCode.GLOBAL_ERROR); - } + var boundJournalIds = await userJournalRepository.Queryable() + .Where(uj => uj.UserId == userId && !uj.IsDeleted && uj.Status == activeStatus) + .Select(uj => uj.JournalId) + .ToListAsync(); + + if (boundJournalIds.Contains(input.JournalId)) + { + logger.LogWarning("用户重复绑定同一期刊,UserId: {UserId}, JournalId: {JournalId}, Id: {Id}", userId, input.JournalId, input.Id); + throw new BusinessException("该用户已绑定过该期刊", ResultCode.BAD_REQUEST); + } + + isFirstBind = boundJournalIds.Count == 0; + boundAt = DateTime.Now; + + // 创建绑定记录 + var updateCount = await userJournalRepository.Updateable() + .SetColumns(uj => uj.UserId == userId) + .SetColumns(uj => uj.UpdatedBy == userId.ToString()) + .SetColumns(uj => uj.UpdatedAt == boundAt) + .Where(uj => uj.Id == input.Id + && uj.JournalId == input.JournalId + && !uj.IsDeleted + && uj.Status == activeStatus + && (uj.UserId == null || uj.UserId == 0)) + .ExecuteCommandAsync(); + + if (updateCount <= 0) + { + logger.LogError("绑定期刊失败,写入数据库失败,UserId: {UserId}, JournalId: {JournalId}", userId, input.JournalId); + throw new BusinessException("绑定期刊失败,请稍后重试", ResultCode.GLOBAL_ERROR); + } + + }); logger.LogInformation("用户绑定期刊成功,UserId: {UserId}, JournalId: {JournalId}, Id: {Id}", userId, input.JournalId, userJournal.Id); userJournal.UserId = userId; userJournal.UpdatedBy = userId.ToString(); - userJournal.UpdatedAt = DateTime.Now; + userJournal.UpdatedAt = boundAt; await SendBindJournalMessageAsync(user, journal); diff --git a/QYZH.InteractiveMagazine.WorkService/QYZH.InteractiveMagazine.WorkService.csproj b/QYZH.InteractiveMagazine.WorkService/QYZH.InteractiveMagazine.WorkService.csproj index b931f2a..d427715 100644 --- a/QYZH.InteractiveMagazine.WorkService/QYZH.InteractiveMagazine.WorkService.csproj +++ b/QYZH.InteractiveMagazine.WorkService/QYZH.InteractiveMagazine.WorkService.csproj @@ -22,4 +22,10 @@ + + + Always + + + diff --git a/QYZH.InteractiveMagazine.WorkService/dotnet-tools.json b/QYZH.InteractiveMagazine.WorkService/dotnet-tools.json new file mode 100644 index 0000000..807729e --- /dev/null +++ b/QYZH.InteractiveMagazine.WorkService/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.9", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file From 519428c52d2388a8f2deac8d1906d59bafecd5f9 Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Thu, 2 Jul 2026 14:28:39 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix(userJournalService):=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E5=86=97=E4=BD=99=E7=9A=84=E7=B1=BB=E5=9E=8B=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除了不必要的Type参数过滤判断,简化用户日志列表查询逻辑 --- QYZH.InteractiveMagazine.Service/UserJournalService.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/QYZH.InteractiveMagazine.Service/UserJournalService.cs b/QYZH.InteractiveMagazine.Service/UserJournalService.cs index 74494e8..75f67e0 100644 --- a/QYZH.InteractiveMagazine.Service/UserJournalService.cs +++ b/QYZH.InteractiveMagazine.Service/UserJournalService.cs @@ -510,7 +510,6 @@ public class UserJournalService( RefAsync totalNumber = 0; var pageResult = await userJournalRepository.Queryable() .Where(uj => uj.UserId == userId) - .WhereIF(!string.IsNullOrWhiteSpace(input.Type), uj => uj.Type.ToString() == input.Type) .OrderByDescending(uj => uj.CreatedAt) .Select(uj => new BindJournalOutput { From 12d488a5ca18eab275d1d57ff98d357dd962d7d5 Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Thu, 2 Jul 2026 16:05:13 +0800 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96AI=E8=AF=84?= =?UTF-8?q?=E5=88=86=E4=B8=8E=E4=BA=8C=E7=BB=B4=E7=A0=81ID=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=80=BB=E8=BE=91=EF=BC=8C=E8=B0=83=E6=95=B4RabbitMQ?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 调整RabbitMQ预取计数配置,支持从配置读取 2. 新增随机ID帮助类,生成唯一长整型ID 3. 重构二维码ID生成逻辑,新增重试机制避免重复 4. 优化AI评分配置,调整温度系数与并发限制 5. 重构跨页题评分逻辑,支持分组评分与结果去重 6. 新增AI评分异常分类与结果校验逻辑 7. 优化评分提示词与结果归一化处理 --- .../Helpers/RandomIdHelper.cs | 37 ++ .../RabbitMQ/RabbitMQService.cs | 13 +- .../UserJournalService.cs | 54 +- .../Consumers/JournalTaskReceiveConsumer.cs | 499 +++++++++++++++--- .../appsettings.json | 6 +- 5 files changed, 524 insertions(+), 85 deletions(-) create mode 100644 QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs diff --git a/QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs b/QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs new file mode 100644 index 0000000..78fd452 --- /dev/null +++ b/QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; + +namespace QYZH.InteractiveMagazine.Common.Helpers; + +/// +/// 随机ID帮助类 +/// +public static class RandomIdHelper +{ + private const long DefaultMinValue = 1_000_000_000_000_000_000L; + private const long DefaultMaxValue = 9_000_000_000_000_000_000L; + + /// + /// 生成不可预测的正数长整型ID + /// + public static long GenerateLongId(long minValue = DefaultMinValue, long maxValue = DefaultMaxValue) + { + if (minValue <= 0 || minValue >= maxValue) + { + throw new ArgumentOutOfRangeException(nameof(minValue), "随机ID范围配置错误"); + } + + var range = (ulong)(maxValue - minValue); + var limit = ulong.MaxValue - (ulong.MaxValue % range); + + Span bytes = stackalloc byte[sizeof(ulong)]; + while (true) + { + RandomNumberGenerator.Fill(bytes); + var value = BitConverter.ToUInt64(bytes); + if (value < limit) + { + return minValue + (long)(value % range); + } + } + } +} diff --git a/QYZH.InteractiveMagazine.Infrastructure/RabbitMQ/RabbitMQService.cs b/QYZH.InteractiveMagazine.Infrastructure/RabbitMQ/RabbitMQService.cs index 2a168e8..7d1cbc1 100644 --- a/QYZH.InteractiveMagazine.Infrastructure/RabbitMQ/RabbitMQService.cs +++ b/QYZH.InteractiveMagazine.Infrastructure/RabbitMQ/RabbitMQService.cs @@ -1,5 +1,6 @@ using RabbitMQ.Client; using RabbitMQ.Client.Events; +using Microsoft.Extensions.Configuration; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; @@ -9,13 +10,15 @@ namespace QYZH.InteractiveMagazine.Infrastructure.RabbitMQ public class RabbitMQService : IRabbitMQService { private readonly IRabbitMQConnection _connection; + private readonly IConfiguration _configuration; private readonly JsonSerializerOptions options = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; - public RabbitMQService(IRabbitMQConnection connection) + public RabbitMQService(IRabbitMQConnection connection, IConfiguration configuration) { _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + _configuration = configuration; } @@ -130,7 +133,13 @@ namespace QYZH.InteractiveMagazine.Infrastructure.RabbitMQ public async Task ReceiveAsync(string exchange, string queueName, string routingKey, Func callback, CancellationToken cancellationToken = default) { var channel = await _connection.CreateChannel(); - //await channel.BasicQosAsync(0, 10, false); // 一次最多接收10条未确认的消息 + var prefetchCount = _configuration.GetValue("RabbitMq:PrefetchCount"); + if (prefetchCount == 0) + { + prefetchCount = 1; + } + + await channel.BasicQosAsync(0, prefetchCount, false, cancellationToken); // 声明 Exchange(持久化) await channel.ExchangeDeclareAsync(exchange: exchange, type: "direct", durable: true, autoDelete: false, arguments: null); diff --git a/QYZH.InteractiveMagazine.Service/UserJournalService.cs b/QYZH.InteractiveMagazine.Service/UserJournalService.cs index 75f67e0..3f3b1b4 100644 --- a/QYZH.InteractiveMagazine.Service/UserJournalService.cs +++ b/QYZH.InteractiveMagazine.Service/UserJournalService.cs @@ -32,6 +32,7 @@ public class UserJournalService( private const string QrCodeGenerateQueue = "mq.journal.qrcode.generate"; private const string QrCodeGenerateRoutingKey = "rk.journal.qrcode.generate"; private const int MaxBatchQrCodeCount = 500; + private const int MaxRandomIdGenerateRetryCount = 5; /// /// 用户绑定期刊(扫码绑定) @@ -183,8 +184,10 @@ public class UserJournalService( throw new BusinessException("该期刊暂未发布,无法生成二维码", ResultCode.UNPROCESSABLE_ENTITY); } + var recordId = await GenerateUniqueQrCodeIdAsync(); var record = new UserJournal { + Id = recordId, UserId = null, JournalId = input.JournalId, Type = 0, @@ -243,9 +246,11 @@ public class UserJournalService( } var now = DateTime.Now; - var records = Enumerable.Range(0, input.Count) - .Select(_ => new UserJournal + var randomIds = await GenerateUniqueQrCodeIdsAsync(input.Count); + var records = randomIds + .Select(id => new UserJournal { + Id = id, UserId = null, JournalId = input.JournalId, Type = 0, @@ -459,6 +464,51 @@ public class UserJournalService( return JsonSerializer.Serialize(new { JournalId = journalId, Id = id }); } + private async Task GenerateUniqueQrCodeIdAsync() + { + for (var i = 0; i < MaxRandomIdGenerateRetryCount; i++) + { + var id = RandomIdHelper.GenerateLongId(); + var exists = await userJournalRepository.Queryable() + .AnyAsync(uj => uj.Id == id); + + if (!exists) + { + return id; + } + } + + throw new BusinessException("生成二维码ID失败,请稍后重试", ResultCode.GLOBAL_ERROR); + } + + private async Task> GenerateUniqueQrCodeIdsAsync(int count) + { + var ids = new HashSet(); + + for (var i = 0; i < MaxRandomIdGenerateRetryCount && ids.Count < count; i++) + { + while (ids.Count < count) + { + ids.Add(RandomIdHelper.GenerateLongId()); + } + + var candidateIds = ids.ToList(); + var existingIds = await userJournalRepository.Queryable() + .Where(uj => candidateIds.Contains(uj.Id)) + .Select(uj => uj.Id) + .ToListAsync(); + + if (existingIds.Count == 0) + { + return candidateIds; + } + + ids.ExceptWith(existingIds); + } + + throw new BusinessException("生成二维码ID失败,请稍后重试", ResultCode.GLOBAL_ERROR); + } + private async Task SendBindJournalMessageAsync(Users user, Journal journal) { try diff --git a/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs b/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs index 6152a7d..eac5b1b 100644 --- a/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs +++ b/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs @@ -21,9 +21,14 @@ public class JournalTaskReceiveConsumer( { private const int DefaultAiScoreMaxRetryCount = 3; private const int DefaultAiScoreRetryDelayMilliseconds = 1000; + private const int DefaultAiMaxConcurrency = 1; private const long DefaultMaxImageBytes = 10 * 1024 * 1024; private const float DefaultCompletionThreshold = 80; private const float DefaultCommunityScoreThreshold = 90; + private const string AiProcessingMessage = "AI批阅中,请稍后"; + private static readonly object AiSemaphoreLock = new(); + private static SemaphoreSlim? aiSemaphore; + private static int aiSemaphoreLimit; public string Exchange => "ex.journal"; @@ -54,17 +59,38 @@ public class JournalTaskReceiveConsumer( .Where(t => taskIds.Contains(t.Id) && !t.IsDeleted) .ToListAsync(cancellationToken); var taskMap = tasks.ToDictionary(t => t.Id); + var groupIds = tasks.Select(t => t.GroupId > 0 ? t.GroupId : t.Id).Distinct().ToList(); + var groupTasks = await client.Queryable() + .Where(t => groupIds.Contains(t.GroupId) && !t.IsDeleted) + .ToListAsync(cancellationToken); + var persistedTaskMap = groupTasks + .GroupBy(t => t.GroupId > 0 ? t.GroupId : t.Id) + .ToDictionary( + g => g.Key, + g => g.FirstOrDefault(t => t.Id == g.Key) + ?? g.OrderBy(t => t.Id).First()); + var groupTaskIds = groupTasks.Select(t => t.Id).Distinct().ToList(); var referenceAnswers = await client.Queryable() - .Where(a => taskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted) + .Where(a => groupTaskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted) .ToListAsync(cancellationToken); var referenceAnswerMap = referenceAnswers .GroupBy(a => a.JournalPageTaskId) .ToDictionary(g => g.Key, g => g.ToList()); - var page = await client.Queryable() - .Where(p => p.Id == data.PageId && !p.IsDeleted) - .FirstAsync(cancellationToken); + var pageIds = groupTasks.Select(t => t.JournalPageId).Append(data.PageId).Distinct().ToList(); + var pages = await client.Queryable() + .Where(p => pageIds.Contains(p.Id) && !p.IsDeleted) + .ToListAsync(cancellationToken); + var pageMap = pages.ToDictionary(p => p.Id); + var existingAnswers = await client.Queryable() + .Where(a => a.UserId == data.UserId && groupTaskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted) + .ToListAsync(cancellationToken); + var existingAnswerMap = existingAnswers + .GroupBy(a => a.JournalPageTaskId) + .ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.UpdatedAt ?? a.CreatedAt).First()); + + var questionContexts = new List(); var answerContexts = new List(); foreach (var question in data.Questions) { @@ -80,13 +106,55 @@ public class JournalTaskReceiveConsumer( continue; } - referenceAnswerMap.TryGetValue(task.Id, out var taskReferenceAnswers); - var scoreResult = await ScoreQuestionAsync(task, question, taskReferenceAnswers ?? [], cancellationToken); - answerContexts.Add(new JournalAnswerContext( - BuildAnswerEntity(data, question, task, page, scoreResult, GetCompletionThreshold()), - question, - task, - scoreResult)); + pageMap.TryGetValue(task.JournalPageId, out var taskPage); + questionContexts.Add(new JournalQuestionContext(question, task, taskPage)); + } + + foreach (var scoreUnit in BuildScoreUnits(questionContexts)) + { + var persistedContext = BuildPersistedContext(scoreUnit, persistedTaskMap, pageMap); + if (persistedContext == null) + { + logger.LogWarning("期刊任务评分单元缺少可入库任务,UserId: {UserId}, GroupId: {GroupId}", + data.UserId, scoreUnit.GroupId); + continue; + } + + if (IsCompletedSameAnswer(scoreUnit, persistedContext.Task, data, existingAnswerMap)) + { + logger.LogInformation("期刊任务作答已完成且内容未变化,跳过AI评分,UserId: {UserId}, GroupId: {GroupId}, TaskIds: {TaskIds}", + data.UserId, scoreUnit.GroupId, string.Join(",", scoreUnit.Questions.Select(q => q.Task.Id))); + continue; + } + + try + { + var scoreResult = await ScoreQuestionAsync(scoreUnit, referenceAnswerMap, cancellationToken); + var normalizedResult = NormalizeScoreResult(scoreResult, persistedContext.Task); + answerContexts.Add(new JournalAnswerContext( + BuildAnswerEntity(data, scoreUnit, persistedContext.Question, persistedContext.Task, persistedContext.Page, normalizedResult, GetCompletionThreshold()), + persistedContext.Question, + persistedContext.Task, + normalizedResult)); + LogSkippedGroupTasks(scoreUnit, persistedContext.Task); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "期刊任务AI评分失败,已标记为处理中,UserId: {UserId}, GroupId: {GroupId}, TaskIds: {TaskIds}", + data.UserId, scoreUnit.GroupId, string.Join(",", scoreUnit.Questions.Select(q => q.Task.Id))); + + var failureResult = BuildFailureScoreResult(); + answerContexts.Add(new JournalAnswerContext( + BuildAnswerEntity(data, scoreUnit, persistedContext.Question, persistedContext.Task, persistedContext.Page, failureResult, GetCompletionThreshold()), + persistedContext.Question, + persistedContext.Task, + failureResult)); + LogSkippedGroupTasks(scoreUnit, persistedContext.Task); + } } if (answerContexts.Count == 0) @@ -148,9 +216,8 @@ public class JournalTaskReceiveConsumer( } private async Task ScoreQuestionAsync( - JournalPageTask task, - Question question, - List referenceAnswers, + JournalScoreUnit scoreUnit, + Dictionary> referenceAnswerMap, CancellationToken cancellationToken) { var apiKey = configuration["AiChat:ApiKey"]; @@ -158,45 +225,61 @@ public class JournalTaskReceiveConsumer( var model = configuration["AiChat:Model"]; var timeoutSeconds = configuration.GetValue("AiChat:TimeoutSeconds"); var maxTokens = configuration.GetValue("AiChat:MaxTokens"); - var temperature = configuration.GetValue("AiChat:Temperature"); + var temperature = configuration.GetValue("AiChat:Temperature"); if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model)) { throw new InvalidOperationException("AI聊天服务配置不完整,请检查 AiChat 配置节点"); } - var answerImages = await BuildAnswerImageContentsAsync(question, cancellationToken); - if (answerImages.Count == 0) - { - throw new InvalidOperationException($"题目 {question.Id} 缺少答案图片"); - } - - var referenceAnswerImages = await BuildReferenceAnswerImageContentsAsync(task.Id, referenceAnswers, cancellationToken); - var content = new List { new { type = "text", - text = BuildScorePrompt(task, question, answerImages.Count, referenceAnswers, referenceAnswerImages.Count) + text = BuildScorePrompt(scoreUnit, referenceAnswerMap) } }; - foreach (var answerImage in answerImages) - { - content.Add(new - { - type = "image_url", - image_url = new { url = answerImage.DataUrl } - }); - } - - if (referenceAnswerImages.Count > 0) + var totalAnswerImageCount = 0; + foreach (var context in scoreUnit.Questions) { + var answerImages = await BuildAnswerImageContentsAsync(context.Question, cancellationToken); + totalAnswerImageCount += answerImages.Count; content.Add(new { type = "text", - text = $"以下为参考答案图片,共 {referenceAnswerImages.Count} 张。参考答案不是必有,评分时以题目Prompt和学生答案为主。" + text = $"以下为任务 {context.Task.Id} 的学生作答图片,共 {answerImages.Count} 张。" + }); + + foreach (var answerImage in answerImages) + { + content.Add(new + { + type = "image_url", + image_url = new { url = answerImage.DataUrl } + }); + } + } + + if (totalAnswerImageCount == 0) + { + throw new InvalidOperationException($"评分单元 {scoreUnit.GroupId} 缺少答案图片"); + } + + foreach (var context in scoreUnit.Questions) + { + referenceAnswerMap.TryGetValue(context.Task.Id, out var referenceAnswers); + var referenceAnswerImages = await BuildReferenceAnswerImageContentsAsync(context.Task.Id, referenceAnswers ?? [], cancellationToken); + if (referenceAnswerImages.Count == 0) + { + continue; + } + + content.Add(new + { + type = "text", + text = $"以下为任务 {context.Task.Id} 的参考答案图片,共 {referenceAnswerImages.Count} 张。参考答案不是必有,评分时以题目Prompt和学生答案为主。" }); foreach (var referenceAnswerImage in referenceAnswerImages) @@ -226,7 +309,7 @@ public class JournalTaskReceiveConsumer( } }, max_tokens = maxTokens > 0 ? maxTokens : 2000, - temperature = temperature > 0 ? temperature : 0.2, + temperature = temperature is >= 0 ? temperature.Value : 0.1, stream = false, response_format = new { type = "json_object" } }; @@ -236,35 +319,46 @@ public class JournalTaskReceiveConsumer( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); - var responseContent = await SendAiScoreRequestWithRetryAsync( - task.Id, - $"{baseUrl.TrimEnd('/')}/chat/completions", - apiKey, - requestJson, - timeoutSeconds > 0 ? timeoutSeconds : 300, - cancellationToken); - var resultJson = ExtractAssistantContent(responseContent); - var scoreResult = ParseScoreResult(resultJson); - scoreResult.Result = TrimResult(scoreResult.Result); - return scoreResult; + var semaphore = GetAiSemaphore(); + await semaphore.WaitAsync(cancellationToken); + try + { + var scoreResult = await SendAiScoreRequestWithRetryAsync( + scoreUnit.GroupId, + $"{baseUrl.TrimEnd('/')}/chat/completions", + apiKey, + requestJson, + timeoutSeconds > 0 ? timeoutSeconds : 300, + cancellationToken); + scoreResult.Result = TrimResult(scoreResult.Result); + return scoreResult; + } + finally + { + semaphore.Release(); + } } private static string BuildScorePrompt( - JournalPageTask task, - Question question, - int answerImageCount, - List referenceAnswers, - int referenceAnswerImageCount) + JournalScoreUnit scoreUnit, + Dictionary> referenceAnswerMap) { - var referenceAnswerTexts = referenceAnswers + var referenceAnswerTexts = scoreUnit.Questions + .SelectMany(q => + { + referenceAnswerMap.TryGetValue(q.Task.Id, out var answers); + return answers ?? []; + }) .Select(a => a.Answer?.Trim()) .Where(a => !string.IsNullOrWhiteSpace(a)) .Distinct() .ToList(); var prompt = new StringBuilder(); + prompt.AppendLine(scoreUnit.Questions.Count > 1 + ? "这是同一跨页题目的多页作答,请综合全部学生作答图片评分。" + : "这是单页题目作答,请根据学生作答图片评分。"); prompt.AppendLine("参考答案不是必有;若无参考答案,以题目 Prompt 和学生答案为准评分。"); - prompt.AppendLine($"参考答案图片数量:{referenceAnswerImageCount}"); if (referenceAnswerTexts.Count > 0) { prompt.AppendLine("参考答案文本:"); @@ -278,21 +372,27 @@ public class JournalTaskReceiveConsumer( prompt.AppendLine("参考答案文本:无"); } prompt.AppendLine(); - prompt.AppendLine("请根据题目评分 Prompt 和学生答案图片进行评分。"); + prompt.AppendLine("题目信息:"); + foreach (var context in scoreUnit.Questions) + { + prompt.AppendLine($"- TaskId:{context.Task.Id}"); + prompt.AppendLine($" 页码:{context.Page?.PageNum ?? 0}"); + prompt.AppendLine($" 题号:{context.Task.No}"); + prompt.AppendLine($" 题目内容:{context.Task.Task}"); + prompt.AppendLine($" 评分Prompt:{context.Task.Prompt}"); + prompt.AppendLine($" 成长值上限:{context.Task.GrowthPoint}"); + prompt.AppendLine($" 积分上限:{context.Task.Points}"); + prompt.AppendLine($" 理解力上限:{context.Task.Comprehension}"); + prompt.AppendLine($" 判断力上限:{context.Task.Judgment}"); + prompt.AppendLine($" 表达力上限:{context.Task.Expression}"); + prompt.AppendLine($" 说服力上限:{context.Task.Persuasiveness}"); + } prompt.AppendLine(); - prompt.AppendLine($"题目内容:{task.Task}"); - prompt.AppendLine("评分Prompt:"); - prompt.AppendLine(task.Prompt); - prompt.AppendLine(); - prompt.AppendLine("题目配置:"); - prompt.AppendLine($"- 成长值上限:{task.GrowthPoint}"); - prompt.AppendLine($"- 积分上限:{task.Points}"); - prompt.AppendLine($"- 理解力上限:{task.Comprehension}"); - prompt.AppendLine($"- 判断力上限:{task.Judgment}"); - prompt.AppendLine($"- 表达力上限:{task.Expression}"); - prompt.AppendLine($"- 说服力上限:{task.Persuasiveness}"); - prompt.AppendLine(); - prompt.AppendLine($"学生答案图片数量:{answerImageCount}"); + prompt.AppendLine("评分要求:"); + prompt.AppendLine("- 不得超过题目配置中的各项上限。"); + prompt.AppendLine("- 看不清、缺页、无法识别或答案明显不完整时,降低 Completion,不要猜测高分。"); + prompt.AppendLine("- Completion 表示作答完整度,范围 0-100。"); + prompt.AppendLine("- Result 返回 50 字内中文评语。"); prompt.AppendLine(); prompt.AppendLine("只返回如下 JSON 字段:"); prompt.AppendLine("{"); @@ -408,7 +508,7 @@ public class JournalTaskReceiveConsumer( return result; } - private async Task SendAiScoreRequestWithRetryAsync( + private async Task SendAiScoreRequestWithRetryAsync( long taskId, string requestUrl, string apiKey, @@ -446,22 +546,37 @@ public class JournalTaskReceiveConsumer( var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode) { - return responseContent; + var resultJson = ExtractAssistantContent(responseContent); + return ParseScoreResult(resultJson); } logger.LogWarning("AI评分调用失败,TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}, StatusCode: {StatusCode}, Response: {Response}", taskId, attempt, maxRetryCount, response.StatusCode, responseContent); - if (!ShouldRetry(response.StatusCode) || attempt == maxRetryCount) + if (!ShouldRetry(response.StatusCode)) + { + throw new NonRetryAiScoreException($"AI评分调用失败:{response.StatusCode},响应:{responseContent}"); + } + + if (attempt == maxRetryCount) { throw new InvalidOperationException($"AI评分调用失败:{response.StatusCode},响应:{responseContent}"); } } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch (Exception ex) when (attempt < maxRetryCount) + catch (OperationCanceledException ex) when (attempt < maxRetryCount) + { + lastException = ex; + logger.LogWarning(ex, "AI评分调用超时,准备重试,TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}", taskId, attempt, maxRetryCount); + } + catch (OperationCanceledException ex) + { + throw new InvalidOperationException($"AI评分调用超时,TaskId: {taskId}", ex); + } + catch (Exception ex) when (attempt < maxRetryCount && ex is not NonRetryAiScoreException) { lastException = ex; logger.LogWarning(ex, "AI评分调用异常,准备重试,TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}", taskId, attempt, maxRetryCount); @@ -536,6 +651,7 @@ public class JournalTaskReceiveConsumer( private static JournalAnswerScoreResult ParseScoreResult(string resultJson) { var cleanedJson = CleanJsonContent(resultJson); + EnsureRequiredScoreFields(cleanedJson); var result = JsonSerializer.Deserialize(cleanedJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true @@ -572,8 +688,219 @@ public class JournalTaskReceiveConsumer( return text.Trim(); } + private static void EnsureRequiredScoreFields(string json) + { + using var document = JsonDocument.Parse(json); + var requiredFields = new[] + { + nameof(JournalAnswerScoreResult.Score), + nameof(JournalAnswerScoreResult.GrowthPoint), + nameof(JournalAnswerScoreResult.Points), + nameof(JournalAnswerScoreResult.Comprehension), + nameof(JournalAnswerScoreResult.Judgment), + nameof(JournalAnswerScoreResult.Expression), + nameof(JournalAnswerScoreResult.Persuasiveness), + nameof(JournalAnswerScoreResult.Completion), + nameof(JournalAnswerScoreResult.Result) + }; + + foreach (var field in requiredFields) + { + if (!document.RootElement.EnumerateObject().Any(p => string.Equals(p.Name, field, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException($"AI评分结果缺少字段:{field}"); + } + } + } + + private static JournalAnswerScoreResult NormalizeScoreResult(JournalAnswerScoreResult scoreResult, JournalPageTask task) + { + var scoreMax = task.Comprehension + task.Judgment + task.Expression + task.Persuasiveness; + if (scoreMax <= 0) + { + scoreMax = 100; + } + + return new JournalAnswerScoreResult + { + Score = Clamp(scoreResult.Score, 0, scoreMax), + GrowthPoint = (int)Clamp(scoreResult.GrowthPoint, 0, task.GrowthPoint), + Points = (int)Clamp(scoreResult.Points, 0, task.Points), + Comprehension = Clamp(scoreResult.Comprehension, 0, task.Comprehension), + Judgment = Clamp(scoreResult.Judgment, 0, task.Judgment), + Expression = Clamp(scoreResult.Expression, 0, task.Expression), + Persuasiveness = Clamp(scoreResult.Persuasiveness, 0, task.Persuasiveness), + Completion = Clamp(scoreResult.Completion, 0, 100), + Result = TrimResult(scoreResult.Result) + }; + } + + private static float Clamp(float value, float min, float max) + { + if (float.IsNaN(value) || float.IsInfinity(value)) + { + return min; + } + + if (max < min) + { + max = min; + } + + return Math.Min(Math.Max(value, min), max); + } + + private static JournalAnswerScoreResult BuildFailureScoreResult() + { + return new JournalAnswerScoreResult + { + Completion = 0, + Result = AiProcessingMessage + }; + } + + private static List BuildScoreUnits(List contexts) + { + return contexts + .GroupBy(c => c.Task.GroupId > 0 ? c.Task.GroupId : c.Task.Id) + .Select(g => new JournalScoreUnit( + g.Key, + g.OrderBy(c => c.Page?.PageNum ?? 0) + .ThenBy(c => ParseTaskNo(c.Task.No)) + .ThenBy(c => c.Task.Id) + .ToList())) + .ToList(); + } + + private static JournalQuestionContext? BuildPersistedContext( + JournalScoreUnit scoreUnit, + Dictionary persistedTaskMap, + Dictionary pageMap) + { + if (scoreUnit.Questions.Count == 0) + { + return null; + } + + var sourceContext = scoreUnit.Questions.FirstOrDefault(q => q.Task.Id == scoreUnit.GroupId) + ?? scoreUnit.Questions.First(); + var persistedTask = persistedTaskMap.TryGetValue(scoreUnit.GroupId, out var task) + ? task + : sourceContext.Task; + pageMap.TryGetValue(persistedTask.JournalPageId, out var persistedPage); + + return new JournalQuestionContext(sourceContext.Question, persistedTask, persistedPage); + } + + private static int ParseTaskNo(string? taskNo) + { + return int.TryParse(taskNo, out var no) ? no : int.MaxValue; + } + + private static bool IsCompletedSameAnswer( + JournalScoreUnit scoreUnit, + JournalPageTask persistedTask, + QuestionData data, + Dictionary existingAnswerMap) + { + return scoreUnit.Questions.Count > 0 + && existingAnswerMap.TryGetValue(persistedTask.Id, out var existing) + && existing.Status == (int)UserAnswerStatusEnum.Complete + && string.Equals(existing.PageAnswerUrl ?? string.Empty, data.PageAnswerUrl ?? string.Empty, StringComparison.Ordinal) + && string.Equals(existing.AnswerUrl ?? string.Empty, SerializeAnswerUrls(scoreUnit), StringComparison.Ordinal) + && existing.AnswerStartTime == GetAnswerStartTime(scoreUnit) + && existing.AnswerEndTime == GetAnswerEndTime(scoreUnit); + } + + private SemaphoreSlim GetAiSemaphore() + { + var maxConcurrency = configuration.GetValue("AiChat:MaxConcurrency"); + if (maxConcurrency <= 0) + { + maxConcurrency = DefaultAiMaxConcurrency; + } + + lock (AiSemaphoreLock) + { + if (aiSemaphore == null || aiSemaphoreLimit != maxConcurrency) + { + aiSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); + aiSemaphoreLimit = maxConcurrency; + } + + return aiSemaphore; + } + } + + private void LogSkippedGroupTasks(JournalScoreUnit scoreUnit, JournalPageTask persistedTask) + { + var skippedTaskIds = scoreUnit.Questions + .Select(q => q.Task.Id) + .Where(id => id != persistedTask.Id) + .Distinct() + .ToList(); + if (skippedTaskIds.Count == 0) + { + return; + } + + logger.LogInformation("期刊跨页题评分结果仅保存到主任务,GroupId: {GroupId}, PersistedTaskId: {PersistedTaskId}, SkippedTaskIds: {SkippedTaskIds}", + scoreUnit.GroupId, persistedTask.Id, string.Join(",", skippedTaskIds)); + } + + private static string SerializeAnswerUrls(JournalScoreUnit scoreUnit) + { + var answerUrls = scoreUnit.Questions + .SelectMany(q => q.Question.AnswerUrl ?? []) + .Where(url => !string.IsNullOrWhiteSpace(url)) + .Distinct() + .ToArray(); + + return JsonSerializer.Serialize(answerUrls); + } + + private static DateTime GetAnswerStartTime(JournalScoreUnit scoreUnit) + { + var startTimes = scoreUnit.Questions + .Select(q => q.Question.AnswerStartTime) + .Where(t => t != default) + .ToList(); + + return startTimes.Count == 0 ? default : startTimes.Min(); + } + + private static DateTime GetAnswerEndTime(JournalScoreUnit scoreUnit) + { + var endTimes = scoreUnit.Questions + .Select(q => q.Question.AnswerEndTime) + .Where(t => t != default) + .ToList(); + + return endTimes.Count == 0 ? default : endTimes.Max(); + } + + private static int GetAnswerSeconds(JournalScoreUnit scoreUnit) + { + return scoreUnit.Questions.Sum(q => Math.Max(0, q.Question.AnswerTime)); + } + + private static int GetBreakCount(JournalScoreUnit scoreUnit) + { + return scoreUnit.Questions.Sum(q => Math.Max(0, q.Question.BreakCount)); + } + + private static string SerializeBreakTimes(JournalScoreUnit scoreUnit) + { + var breakTimes = scoreUnit.Questions + .SelectMany(q => q.Question.BreakTimes ?? []) + .ToList(); + + return JsonSerializer.Serialize(breakTimes); + } + private static JournalPageTaskUserAnswer BuildAnswerEntity( QuestionData data, + JournalScoreUnit scoreUnit, Question question, JournalPageTask task, JournalPage? page, @@ -603,12 +930,12 @@ public class JournalTaskReceiveConsumer( Expression = Math.Max(0, scoreResult.Expression), Persuasiveness = Math.Max(0, scoreResult.Persuasiveness), QuestionAnswerUrl = question.Url, - AnswerUrl = JsonSerializer.Serialize(question.AnswerUrl ?? []), + AnswerUrl = SerializeAnswerUrls(scoreUnit), PageAnswerUrl = data.PageAnswerUrl, Revision = 0, - AnswerStartTime = question.AnswerStartTime, - AnswerEndTime = question.AnswerEndTime, - AnswerSeconds = question.AnswerTime, + AnswerStartTime = GetAnswerStartTime(scoreUnit), + AnswerEndTime = GetAnswerEndTime(scoreUnit), + AnswerSeconds = GetAnswerSeconds(scoreUnit), ImageRecognition = 0, JournalPageNum = page?.PageNum ?? 0, Modify = 0, @@ -618,8 +945,8 @@ public class JournalTaskReceiveConsumer( Type = task.Type.ToString(), DotPageNo = page?.PageNo ?? string.Empty, PageAnswerDotUrl = string.Empty, - BreakCount = question.BreakCount, - BreakTimes = JsonSerializer.Serialize(question.BreakTimes ?? []), + BreakCount = GetBreakCount(scoreUnit), + BreakTimes = SerializeBreakTimes(scoreUnit), AssignmentStatus = answerStatus.ToString(), Status = (int)answerStatus, CreatedBy = data.UserId.ToString(), @@ -911,4 +1238,18 @@ public record JournalAnswerContext( JournalPageTask Task, JournalAnswerScoreResult ScoreResult); +public record JournalQuestionContext( + Question Question, + JournalPageTask Task, + JournalPage? Page); + +public record JournalScoreUnit( + long GroupId, + List Questions); + public record AnswerImageContent(string DataUrl); + +/// +/// AI评分不可重试异常。 +/// +public class NonRetryAiScoreException(string message) : Exception(message); diff --git a/QYZH.InteractiveMagazine.WorkService/appsettings.json b/QYZH.InteractiveMagazine.WorkService/appsettings.json index 3585c67..bb9a4f7 100644 --- a/QYZH.InteractiveMagazine.WorkService/appsettings.json +++ b/QYZH.InteractiveMagazine.WorkService/appsettings.json @@ -12,7 +12,8 @@ "Port": 5672, "UserName": "smartschool", "Password": "@ss%&*otz%d*pq2S", - "VirtualHost": "InteractiveMagazine" + "VirtualHost": "InteractiveMagazine", + "PrefetchCount": 1 }, "Serilog": { "MinimumLevel": { @@ -55,7 +56,8 @@ "Model": "qwen2.5vl:7b", "TimeoutSeconds": 300, "MaxTokens": 2000, - "Temperature": 0.5, + "Temperature": 0.1, + "MaxConcurrency": 1, "ScoreMaxRetryCount": 3, "ScoreRetryDelayMilliseconds": 1000, "MaxImageBytes": 10485760, From 88e5fd23bec9aac6cd0e3b0353f3d2bcb146294f Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Thu, 2 Jul 2026 16:16:24 +0800 Subject: [PATCH 7/9] refactor(common&service): adjust id range and remove unused qr code method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 调整RandomIdHelper的默认ID生成范围从16位改为13位 2. 从IUserJournalService和UserJournalService中移除废弃的单例生成二维码方法 3. 为相关DTO添加JSON数字序列化/反序列化配置,处理前后端数字类型兼容问题 4. 修改二维码内容序列化逻辑,将数字转为字符串避免精度丢失 --- .../Helpers/RandomIdHelper.cs | 4 +- .../IUserJournalService.cs | 8 --- .../Dto/Journal/BindJournalDto.cs | 6 ++ .../UserJournalService.cs | 58 +------------------ 4 files changed, 9 insertions(+), 67 deletions(-) diff --git a/QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs b/QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs index 78fd452..0845d83 100644 --- a/QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs +++ b/QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs @@ -7,8 +7,8 @@ namespace QYZH.InteractiveMagazine.Common.Helpers; /// public static class RandomIdHelper { - private const long DefaultMinValue = 1_000_000_000_000_000_000L; - private const long DefaultMaxValue = 9_000_000_000_000_000_000L; + private const long DefaultMinValue = 1_000_000_000_000L; + private const long DefaultMaxValue = 9_000_000_000_000_000L; /// /// 生成不可预测的正数长整型ID diff --git a/QYZH.InteractiveMagazine.IService/IUserJournalService.cs b/QYZH.InteractiveMagazine.IService/IUserJournalService.cs index 0528b2f..8c9a7ed 100644 --- a/QYZH.InteractiveMagazine.IService/IUserJournalService.cs +++ b/QYZH.InteractiveMagazine.IService/IUserJournalService.cs @@ -31,14 +31,6 @@ public interface IUserJournalService : IBaseService /// 绑定记录Id Task UnbindJournalAsync(long userId, long id); - /// - /// 生成期刊二维码记录 - /// - /// 生成输入 - /// 操作人名称 - /// 二维码记录 - Task CreateQrCodeAsync(CreateUserJournalQrCodeInput input, string operatorName); - /// /// 批量提交期刊二维码生成任务 /// diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs index 0907687..d9392a8 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs @@ -1,4 +1,5 @@ using QYZH.InteractiveMagazine.Models.Enum; +using System.Text.Json.Serialization; namespace QYZH.InteractiveMagazine.Models.Dto; @@ -10,11 +11,13 @@ public class BindJournalInput /// /// 期刊模板Id(扫码解析的期刊定义Id) /// + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public long JournalId { get; set; } /// /// 实例化期刊Id(扫码解析的具体期刊实例Id,可选) /// + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public long Id { get; set; } } @@ -146,6 +149,7 @@ public class CreateUserJournalQrCodeOutput /// /// 二维码记录Id列表 /// + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] public List RecordIds { get; set; } = []; /// @@ -209,6 +213,7 @@ public class UserJournalQrCodeOutput /// /// 二维码记录Id /// + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] public long Id { get; set; } /// @@ -270,5 +275,6 @@ public class DeleteUserJournalQrCodeInput /// /// 二维码记录Id列表 /// + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public List Ids { get; set; } = []; } diff --git a/QYZH.InteractiveMagazine.Service/UserJournalService.cs b/QYZH.InteractiveMagazine.Service/UserJournalService.cs index 3f3b1b4..a1085ef 100644 --- a/QYZH.InteractiveMagazine.Service/UserJournalService.cs +++ b/QYZH.InteractiveMagazine.Service/UserJournalService.cs @@ -166,62 +166,6 @@ public class UserJournalService( /// /// 生成期刊二维码记录 /// - public async Task CreateQrCodeAsync(CreateUserJournalQrCodeInput input, string operatorName) - { - if (input.JournalId <= 0) - { - throw new BusinessException("期刊Id不能为空", ResultCode.BAD_REQUEST); - } - - var journal = await journalRepository.GetByIdAsync(input.JournalId); - if (journal == null || journal.IsDeleted) - { - throw new BusinessException("期刊不存在", ResultCode.NOT_FOUND); - } - - if (journal.Status != (int)JournalStatusEnum.Published) - { - throw new BusinessException("该期刊暂未发布,无法生成二维码", ResultCode.UNPROCESSABLE_ENTITY); - } - - var recordId = await GenerateUniqueQrCodeIdAsync(); - var record = new UserJournal - { - Id = recordId, - UserId = null, - JournalId = input.JournalId, - Type = 0, - Status = (int)UserJournalStatusEnum.Active, - IsDeleted = false, - CreatedBy = operatorName, - CreatedAt = DateTime.Now, - UpdatedBy = operatorName, - UpdatedAt = DateTime.Now - }; - - var qrCodeContent = BuildQrCodeContent(record.JournalId, record.Id); - var qrCodeKey = $"journal/qrcode/{record.JournalId}/{record.Id}.png"; - using var qrCodeStream = new MemoryStream(QrCodeHelper.GeneratePng(qrCodeContent)); - var uploadedKey = ossService.PutObject(qrCodeKey, qrCodeStream); - if (string.IsNullOrWhiteSpace(uploadedKey)) - { - throw new BusinessException("二维码图片上传失败,请稍后重试", ResultCode.GLOBAL_ERROR); - } - - record.QrCodeUrl = uploadedKey; - - var result = await userJournalRepository.InsertAsync(record); - if (!result) - { - throw new BusinessException("生成二维码失败,请稍后重试", ResultCode.GLOBAL_ERROR); - } - - return MapQrCodeOutput(record, journal, null); - } - - /// - /// 分页查询期刊二维码记录 - /// public async Task CreateQrCodesAsync(CreateUserJournalQrCodeInput input, string operatorName) { if (input.JournalId <= 0) @@ -461,7 +405,7 @@ public class UserJournalService( private static string BuildQrCodeContent(long journalId, long id) { - return JsonSerializer.Serialize(new { JournalId = journalId, Id = id }); + return JsonSerializer.Serialize(new { JournalId = journalId.ToString(), Id = id.ToString() }); } private async Task GenerateUniqueQrCodeIdAsync() From f8874ff60ca60488d647217936a88598713a8faf Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Fri, 3 Jul 2026 11:24:28 +0800 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E7=94=A8=E6=88=B7=E5=90=8D=E5=AD=97=E6=AE=B5=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7=E6=95=B0=E6=8D=AE=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=8F=8A=E6=8E=A5=E5=8F=A3=E8=AF=B7=E6=B1=82=E6=96=B9?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增WxUserName字段到UsersOutput Dto用于存储微信用户名 2. 关联WxUser表查询以获取微信用户名数据 3. 将用户相关的GET接口修改为POST接口,使用FromBody接收参数 --- .../Dto/UsersDto.cs | 5 +++ .../UsersService.cs | 43 ++++++++++++++++--- .../Controllers/UsersController.cs | 20 ++++----- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/QYZH.InteractiveMagazine.Models/Dto/UsersDto.cs b/QYZH.InteractiveMagazine.Models/Dto/UsersDto.cs index 2613141..3ac9930 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/UsersDto.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/UsersDto.cs @@ -26,6 +26,11 @@ public class UsersOutput /// public string WxUserId { get; set; } = string.Empty; + /// + /// 微信用户名 + /// + public string? WxUserName { get; set; } + /// /// 昵称 /// diff --git a/QYZH.InteractiveMagazine.Service/UsersService.cs b/QYZH.InteractiveMagazine.Service/UsersService.cs index f7f3ea6..240027f 100644 --- a/QYZH.InteractiveMagazine.Service/UsersService.cs +++ b/QYZH.InteractiveMagazine.Service/UsersService.cs @@ -28,10 +28,27 @@ public class UsersService( /// public async Task>> GetListAsync(UsersQueryInput input) { - var page = Queryable() - .WhereIF(!string.IsNullOrEmpty(input.WxUserId), u => u.WxUserId.ToString() == input.WxUserId) - .OrderBy(u => u.Id, OrderByType.Desc) - .ToPage(input); + RefAsync totalNumber = 0; + var list = await Queryable() + .LeftJoin((u, w) => u.WxUserId == w.Id && !w.IsDeleted) + .WhereIF(!string.IsNullOrEmpty(input.WxUserId), (u, w) => u.WxUserId.ToString() == input.WxUserId) + .OrderBy((u, w) => u.Id, OrderByType.Desc) + .Select((u, w) => new UsersOutput + { + Id = u.Id, + WxUserId = u.WxUserId.ToString(), + WxUserName = w.Name, + Name = u.Name, + AvatarUrl = u.AvatarUrl, + Points = u.Points, + Type = u.Type.ToString(), + Status = u.Status.ToString(), + GrowthPoints = u.GrowthPoints, + UploadDomain = u.UploadDomain + }) + .ToPageListAsync(input.PageIndex, input.PageSize, totalNumber); + + var page = new PageListModel(list, input.PageIndex, input.PageSize, totalNumber); return BaseResponse>.Success(page); } @@ -41,7 +58,23 @@ public class UsersService( /// public async Task> GetDetailAsync(long id) { - var user = await GetByIdAsync(u => u.Id == id); + var user = await Queryable() + .LeftJoin((u, w) => u.WxUserId == w.Id && !w.IsDeleted) + .Where((u, w) => u.Id == id) + .Select((u, w) => new UsersOutput + { + Id = u.Id, + WxUserId = u.WxUserId.ToString(), + WxUserName = w.Name, + Name = u.Name, + AvatarUrl = u.AvatarUrl, + Points = u.Points, + Type = u.Type.ToString(), + Status = u.Status.ToString(), + GrowthPoints = u.GrowthPoints, + UploadDomain = u.UploadDomain + }) + .FirstAsync(); if (user == null) { return BaseResponse.Fail("用户不存在"); diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/UsersController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/UsersController.cs index ebc6763..4873972 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/UsersController.cs +++ b/QYZH.InteractiveMagazine.WebApi/Controllers/UsersController.cs @@ -28,8 +28,8 @@ public class UsersController : BaseController /// /// 获取用户列表(分页) /// - [HttpGet] - public async Task>> GetList([FromQuery] UsersQueryInput input) + [HttpPost] + public async Task>> GetList([FromBody] UsersQueryInput input) { return await _usersService.GetListAsync(input); } @@ -46,8 +46,8 @@ public class UsersController : BaseController /// /// 分页查询用户积分记录 /// - [HttpGet("{id}/pointsRecords")] - public async Task>> GetUserPointsRecords(long id, [FromQuery] PointsRecordQueryInput input) + [HttpPost("{id}/pointsRecords")] + public async Task>> GetUserPointsRecords(long id, [FromBody] PointsRecordQueryInput input) { return await _usersService.GetUserPointsRecordsAsync(id, input); } @@ -55,8 +55,8 @@ public class UsersController : BaseController /// /// 分页查询用户签到记录 /// - [HttpGet("{id}/checkInRecords")] - public async Task>> GetUserCheckInRecords(long id, [FromQuery] PageQueryModel input) + [HttpPost("{id}/checkInRecords")] + public async Task>> GetUserCheckInRecords(long id, [FromBody] PageQueryModel input) { return await _usersService.GetUserCheckInRecordsAsync(id, input); } @@ -64,8 +64,8 @@ public class UsersController : BaseController /// /// 分页查询用户补偿任务 /// - [HttpGet("{id}/compensationTasks")] - public async Task>> GetUserCompensationTasks(long id, [FromQuery] CompensationTaskQueryInput input) + [HttpPost("{id}/compensationTasks")] + public async Task>> GetUserCompensationTasks(long id, [FromBody] CompensationTaskQueryInput input) { return await _usersService.GetUserCompensationTasksAsync(id, input); } @@ -73,8 +73,8 @@ public class UsersController : BaseController /// /// 分页查询用户期刊列表 /// - [HttpGet("{id}/journals")] - public async Task>> GetUserJournals(long id, [FromQuery] UserJournalQueryInput input) + [HttpPost("{id}/journals")] + public async Task>> GetUserJournals(long id, [FromBody] UserJournalQueryInput input) { return await _usersService.GetUserJournalsAsync(id, input); } From a04b4be7e5f793b55c12c9e85dfffee9dba69532 Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Mon, 6 Jul 2026 14:21:21 +0800 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1API=E5=88=B0=E7=8B=AC=E7=AB=8B=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=B9=B6=E8=BF=81=E7=A7=BB=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增WeChatApi独立项目,将原WebApi中的微信相关控制器迁移至新项目 2. 新增后台权限管理相关实体、服务接口和基础控制器 3. 完善管理员用户服务,增加角色关联查询和赋值逻辑 4. 修复WebApi Swagger文档过滤微信API版本的问题 5. 更新解决方案文件,添加新的微信API项目引用 6. 新增微信API基础配置文件和项目属性配置 --- CommunityMessage_TestData.sql | 129 ----- .../IAdminPermissionService.cs | 80 +++ .../Dto/Admin/AdminPermissionDto.cs | 270 ++++++++++ .../Dto/Admin/AdminUserDto.cs | 15 + .../Dto/Admin/AuthDto.cs | 40 ++ .../Entity/AdminMenu.cs | 66 +++ .../Entity/AdminRole.cs | 31 ++ .../Entity/AdminRoleMenu.cs | 24 + .../Entity/AdminUserRole.cs | 24 + .../AdminAuthService.cs | 24 +- .../AdminPermissionService.cs | 495 ++++++++++++++++++ .../AdminUserService.cs | 55 +- .../Controllers}/BagController.cs | 2 +- .../Controllers}/CheckInController.cs | 2 +- .../Controllers}/CommunityController.cs | 2 +- .../Controllers}/JournalController.cs | 2 +- .../Controllers}/MallController.cs | 2 +- .../Controllers}/MedalController.cs | 2 +- .../Controllers}/PetController.cs | 2 +- .../Controllers}/UserAnswerTaskController.cs | 2 +- .../Controllers}/WeChatAuthController.cs | 2 +- .../Controllers}/WeChatBaseController.cs | 2 +- QYZH.InteractiveMagazine.WeChatApi/Program.cs | 189 +++++++ .../Properties/launchSettings.json | 41 ++ .../QYZH.InteractiveMagazine.WeChatApi.csproj | 25 + .../appsettings.Development.json | 8 + .../appsettings.json | 71 +++ .../medal-rule-config.json | 43 ++ .../Controllers/PermissionController.cs | 139 +++++ QYZH.InteractiveMagazine.WebApi/Program.cs | 5 +- .../Consumers/JournalTaskReceiveConsumer.cs | 32 ++ QYZH.InteractiveMagazine.slnx | 1 + 32 files changed, 1669 insertions(+), 158 deletions(-) delete mode 100644 CommunityMessage_TestData.sql create mode 100644 QYZH.InteractiveMagazine.IService/IAdminPermissionService.cs create mode 100644 QYZH.InteractiveMagazine.Models/Dto/Admin/AdminPermissionDto.cs create mode 100644 QYZH.InteractiveMagazine.Models/Entity/AdminMenu.cs create mode 100644 QYZH.InteractiveMagazine.Models/Entity/AdminRole.cs create mode 100644 QYZH.InteractiveMagazine.Models/Entity/AdminRoleMenu.cs create mode 100644 QYZH.InteractiveMagazine.Models/Entity/AdminUserRole.cs create mode 100644 QYZH.InteractiveMagazine.Service/AdminPermissionService.cs rename {QYZH.InteractiveMagazine.WebApi/Controllers/WeChat => QYZH.InteractiveMagazine.WeChatApi/Controllers}/BagController.cs (96%) rename {QYZH.InteractiveMagazine.WebApi/Controllers/WeChat => QYZH.InteractiveMagazine.WeChatApi/Controllers}/CheckInController.cs (98%) rename {QYZH.InteractiveMagazine.WebApi/Controllers/WeChat => QYZH.InteractiveMagazine.WeChatApi/Controllers}/CommunityController.cs (98%) rename {QYZH.InteractiveMagazine.WebApi/Controllers/WeChat => QYZH.InteractiveMagazine.WeChatApi/Controllers}/JournalController.cs (98%) rename {QYZH.InteractiveMagazine.WebApi/Controllers/WeChat => QYZH.InteractiveMagazine.WeChatApi/Controllers}/MallController.cs (97%) rename {QYZH.InteractiveMagazine.WebApi/Controllers/WeChat => QYZH.InteractiveMagazine.WeChatApi/Controllers}/MedalController.cs (98%) rename {QYZH.InteractiveMagazine.WebApi/Controllers/WeChat => QYZH.InteractiveMagazine.WeChatApi/Controllers}/PetController.cs (98%) rename {QYZH.InteractiveMagazine.WebApi/Controllers/WeChat => QYZH.InteractiveMagazine.WeChatApi/Controllers}/UserAnswerTaskController.cs (99%) rename {QYZH.InteractiveMagazine.WebApi/Controllers/WeChat => QYZH.InteractiveMagazine.WeChatApi/Controllers}/WeChatAuthController.cs (99%) rename {QYZH.InteractiveMagazine.WebApi/Controllers/WeChat => QYZH.InteractiveMagazine.WeChatApi/Controllers}/WeChatBaseController.cs (97%) create mode 100644 QYZH.InteractiveMagazine.WeChatApi/Program.cs create mode 100644 QYZH.InteractiveMagazine.WeChatApi/Properties/launchSettings.json create mode 100644 QYZH.InteractiveMagazine.WeChatApi/QYZH.InteractiveMagazine.WeChatApi.csproj create mode 100644 QYZH.InteractiveMagazine.WeChatApi/appsettings.Development.json create mode 100644 QYZH.InteractiveMagazine.WeChatApi/appsettings.json create mode 100644 QYZH.InteractiveMagazine.WeChatApi/medal-rule-config.json create mode 100644 QYZH.InteractiveMagazine.WebApi/Controllers/PermissionController.cs diff --git a/CommunityMessage_TestData.sql b/CommunityMessage_TestData.sql deleted file mode 100644 index 375122f..0000000 --- a/CommunityMessage_TestData.sql +++ /dev/null @@ -1,129 +0,0 @@ --- ===================================================== --- CommunityMessage 社区预置消息表 - 20条模拟测试数据 --- 生成时间: 2026-06-12 --- ===================================================== - -INSERT INTO [dbo].[CommunityMessage] - ([Id], [Status], [IsDeleted], [CreatedBy], [CreatedAt], [UpdatedBy], [UpdatedAt], - [JournalId], [UserId], [UserJournalId], [JournalTaskId], [JournalTaskAnswerId], - [Content], [ImageUrl], [SortOrder], [IsActive], [Type], [LikeCount], [IsFeatured]) -VALUES --- 1. 文章类型 - 期刊导读 -(1916234567890001, 1, 0, N'系统管理员', '2026-05-01 09:00:00', NULL, NULL, - 1001, 5001, 8001, 3001, 6001, - N'本期期刊聚焦人工智能在教育领域的创新应用,欢迎各位读者踊跃讨论。', - NULL, 1, 1, 1, 42, 1), - --- 2. 引用类型 - 名人名言 -(1916234567890002, 1, 0, N'编辑小王', '2026-05-02 10:30:00', NULL, NULL, - 1001, 5002, 8002, 3001, 6002, - N'"教育不是灌输,而是点燃火焰。" —— 苏格拉底', - NULL, 2, 1, 2, 88, 1), - --- 3. 公告类型 - 活动通知 -(1916234567890003, 1, 0, N'系统管理员', '2026-05-03 14:00:00', N'系统管理员', '2026-05-03 15:00:00', - 1001, 5003, 8003, 3002, 6003, - N'【活动通知】第十二期线上读书分享会将于本周六晚8点举行,主题为"科技与人文的对话",欢迎大家报名参加!', - N'/uploads/images/activity_notice_202605.png', 1, 1, 3, 156, 1), - --- 4. 文章类型 - 读者投稿 -(1916234567890004, 1, 0, N'读者张三', '2026-05-04 08:15:00', NULL, NULL, - 1002, 5004, 8004, 3003, 6004, - N'阅读了本期的深度报道《数字时代的阅读习惯变迁》,深有感触。作为一个从纸质书时代走过来的人,既感慨科技带来的便利,也怀念翻页时的触感。', - NULL, 3, 1, 1, 35, 0), - --- 5. 引用类型 - 期刊摘录 -(1916234567890005, 1, 0, N'编辑小李', '2026-05-05 11:20:00', NULL, NULL, - 1002, 5005, 8005, 3003, 6005, - N'摘自本期特稿:"在信息爆炸的时代,深度阅读能力正在成为一种稀缺的核心竞争力。"', - N'/uploads/images/quote_bg_01.png', 4, 1, 2, 67, 1), - --- 6. 文章类型 - 互动讨论 -(1916234567890006, 1, 0, N'读者李四', '2026-05-06 16:45:00', NULL, NULL, - 1001, 5006, 8006, 3001, 6006, - N'关于本期讨论话题"AI是否会取代教师",我认为技术只是工具,教育的本质是人与人之间灵魂的碰撞,这是机器无法替代的。', - NULL, 5, 1, 1, 92, 1), - --- 7. 公告类型 - 系统维护 -(1916234567890007, 1, 0, N'系统管理员', '2026-05-07 09:00:00', N'系统管理员', '2026-05-07 09:30:00', - 1001, 5001, 8001, 3001, 6001, - N'【系统公告】平台将于5月10日凌晨2:00-5:00进行系统升级维护,届时服务将短暂中断,请各位读者提前保存阅读进度。', - NULL, 1, 1, 3, 23, 0), - --- 8. 文章类型 - 编辑寄语 -(1916234567890008, 1, 0, N'主编老陈', '2026-05-08 07:30:00', NULL, NULL, - 1003, 5007, 8007, 3004, 6007, - N'新一期杂志如期而至。本期我们特别策划了"青年创作者专栏",收录了十位90后作家的原创作品,期待听到你们的声音。', - N'/uploads/images/editor_letter_05.jpg', 1, 1, 1, 210, 1), - --- 9. 引用类型 - 读者金句 -(1916234567890009, 1, 0, N'读者王五', '2026-05-09 20:10:00', NULL, NULL, - 1002, 5008, 8008, 3003, 6008, - N'"每一本杂志都是一扇窗,让我们看到不同的人生风景。" 感谢社区里每一位分享者。', - NULL, 6, 1, 2, 54, 0), - --- 10. 文章类型 - 话题互动 -(1916234567890010, 1, 0, N'读者赵六', '2026-05-10 13:55:00', NULL, NULL, - 1003, 5009, 8009, 3004, 6009, - N'看完"城市记忆"系列第三篇,想起了小时候胡同里的叫卖声和邻居家的饭菜香。城市化进程中,我们该如何留住这些温暖的记忆?', - N'/uploads/images/city_memory_03.jpg', 7, 1, 1, 78, 1), - --- 11. 公告类型 - 征稿启事 -(1916234567890011, 1, 0, N'编辑小王', '2026-05-11 10:00:00', N'编辑小王', '2026-05-12 08:00:00', - 1003, 5010, 8010, 3005, 6010, - N'【征稿启事】第十三期"我的阅读故事"主题征稿正式启动!征稿时间:5月11日-6月11日,优秀作品将刊登在杂志特辑中,期待您的来稿。', - N'/uploads/images/call_for_papers_2026.png', 2, 1, 3, 189, 1), - --- 12. 文章类型 - 读书笔记 -(1916234567890012, 1, 0, N'读者孙七', '2026-05-12 19:30:00', NULL, NULL, - 1001, 5011, 8011, 3001, 6011, - N'本期推荐的三本书我都读完了,最喜欢《思考,快与慢》。作者对直觉思维和理性思维的分析让人醍醐灌顶,强烈推荐给所有社区朋友。', - NULL, 8, 1, 1, 45, 0), - --- 13. 引用类型 - 经典回顾 -(1916234567890013, 1, 0, N'编辑小李', '2026-05-13 15:00:00', NULL, NULL, - 1002, 5002, 8002, 3003, 6012, - N'回顾创刊号寄语:"我们相信,好的内容值得被慢慢品味。在这个快节奏的时代,让我们一起做慢阅读的实践者。"', - N'/uploads/images/anniversary_retro.png', 3, 1, 2, 134, 1), - --- 14. 文章类型 - 作者互动 -(1916234567890014, 1, 0, N'作者林清', '2026-05-14 21:45:00', NULL, NULL, - 1003, 5012, 8012, 3004, 6013, - N'感谢大家对《城市记忆》系列的支持和反馈!每一条评论我都认真阅读了。你们的共鸣是我继续创作的最大动力,下一篇将聚焦"消失的声音"。', - NULL, 9, 1, 1, 167, 1), - --- 15. 公告类型 - 获奖公告 -(1916234567890015, 1, 0, N'系统管理员', '2026-05-15 12:00:00', N'系统管理员', '2026-05-15 14:00:00', - 1001, 5001, 8001, 3002, 6001, - N'【获奖公告】第十一期"最佳读者评论"评选结果揭晓!恭喜读者@书香满园、@夜读人、@思考者三位获得本期精选评论奖,奖品将于一周内寄送。', - N'/uploads/images/award_11.jpg', 4, 1, 3, 302, 1), - --- 16. 文章类型 - 生活随笔 -(1916234567890016, 1, 0, N'读者周八', '2026-05-16 07:00:00', NULL, NULL, - 1002, 5013, 8013, 3003, 6014, - N'清晨六点,一杯咖啡,一本杂志。这已经成了我三年来雷打不动的习惯。今天的这篇关于极简生活的文章,正好呼应了我最近的生活理念——少即是多。', - N'/uploads/images/morning_read.jpg', 10, 1, 1, 29, 0), - --- 17. 引用类型 - 编辑精选 -(1916234567890017, 1, 0, N'主编老陈', '2026-05-17 16:30:00', NULL, NULL, - 1003, 5007, 8007, 3004, 6015, - N'本期编辑精选语录:"真正的知识不在于拥有多少信息,而在于能用多少方式去理解世界。" —— 选自专栏文章《认知的边界》', - NULL, 5, 1, 2, 95, 1), - --- 18. 文章类型 - 问题探讨 -(1916234567890018, 1, 0, N'读者吴九', '2026-05-18 22:15:00', NULL, NULL, - 1001, 5014, 8014, 3001, 6016, - N'想请教大家,本期提到的"元宇宙图书馆"概念,你们觉得在技术层面还有多远?VR阅读体验是否真的能超越传统阅读?期待和大家探讨。', - NULL, 11, 1, 1, 61, 0), - --- 19. 公告类型 - 订阅优惠 -(1916234567890019, 1, 0, N'系统管理员', '2026-05-19 09:30:00', NULL, NULL, - 1002, 5003, 8003, 3002, 6003, - N'【限时优惠】杂志年度订阅特惠活动开启!5月20日-5月31日期间订阅全年杂志享7折优惠,老用户续费额外赠送限量版精装合集一本。', - N'/uploads/images/subscription_promo_2026.png', 6, 1, 3, 278, 0), - --- 20. 文章类型 - 社区感言 -(1916234567890020, 1, 0, N'读者郑十', '2026-05-20 18:00:00', NULL, NULL, - 1003, 5015, 8015, 3004, 6017, - N'加入这个阅读社区整整一年了,从最初的潜水到现在的积极互动,在这里认识了很多志同道合的朋友。感谢平台的用心运营,让我们一起在文字中找到归属。', - N'/uploads/images/community_anniversary.jpg', 12, 1, 1, 445, 1); diff --git a/QYZH.InteractiveMagazine.IService/IAdminPermissionService.cs b/QYZH.InteractiveMagazine.IService/IAdminPermissionService.cs new file mode 100644 index 0000000..fa8931c --- /dev/null +++ b/QYZH.InteractiveMagazine.IService/IAdminPermissionService.cs @@ -0,0 +1,80 @@ +using QYZH.InteractiveMagazine.Models.Dto; +using QYZH.InteractiveMagazine.Models.Entity; + +namespace QYZH.InteractiveMagazine.IService; + +/// +/// 后台权限管理服务接口 +/// +public interface IAdminPermissionService : IBaseService +{ + /// + /// 创建菜单 + /// + Task CreateMenuAsync(AdminMenuInput input); + + /// + /// 更新菜单 + /// + Task UpdateMenuAsync(long id, AdminMenuInput input); + + /// + /// 删除菜单 + /// + Task DeleteMenuAsync(long id); + + /// + /// 获取菜单树 + /// + Task> GetMenuTreeAsync(AdminMenuQueryInput input); + + /// + /// 创建角色 + /// + Task CreateRoleAsync(AdminRoleInput input); + + /// + /// 更新角色 + /// + Task UpdateRoleAsync(long id, AdminRoleInput input); + + /// + /// 删除角色 + /// + Task DeleteRoleAsync(long id); + + /// + /// 获取角色详情 + /// + Task GetRoleByIdAsync(long id); + + /// + /// 获取角色分页列表 + /// + Task> GetRoleListAsync(AdminRoleQueryInput input); + + /// + /// 分配角色菜单 + /// + Task AssignRoleMenusAsync(long roleId, AssignRoleMenusInput input); + + /// + /// 分配管理员角色 + /// + Task AssignAdminUserRolesAsync(long adminUserId, AssignAdminUserRolesInput input); + + /// + /// 获取管理员菜单树 + /// + Task> GetAdminUserMenuTreeAsync(long adminUserId); + + /// + /// 获取管理员角色 + /// + Task> GetAdminUserRolesAsync(long adminUserId); + + /// + /// 获取管理员权限编码 + /// + Task> GetAdminUserPermissionCodesAsync(long adminUserId); +} diff --git a/QYZH.InteractiveMagazine.Models/Dto/Admin/AdminPermissionDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Admin/AdminPermissionDto.cs new file mode 100644 index 0000000..08855c8 --- /dev/null +++ b/QYZH.InteractiveMagazine.Models/Dto/Admin/AdminPermissionDto.cs @@ -0,0 +1,270 @@ +namespace QYZH.InteractiveMagazine.Models.Dto; + +/// +/// 后台菜单输入 +/// +public class AdminMenuInput +{ + /// + /// 父级菜单ID,根节点为0 + /// + public long ParentId { get; set; } + + /// + /// 菜单名称 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 权限编码 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 前端路由路径 + /// + public string? Path { get; set; } + + /// + /// 前端组件路径 + /// + public string? Component { get; set; } + + /// + /// 菜单图标 + /// + public string? Icon { get; set; } + + /// + /// 排序值 + /// + public int Sort { get; set; } + + /// + /// 是否显示在菜单 + /// + public bool IsVisible { get; set; } = true; + + /// + /// 状态 + /// + public int Status { get; set; } = 1; +} + +/// +/// 后台菜单输出 +/// +public class AdminMenuOutput +{ + /// + /// 菜单ID + /// + public long Id { get; set; } + + /// + /// 父级菜单ID + /// + public long ParentId { get; set; } + + /// + /// 菜单名称 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 权限编码 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 前端路由路径 + /// + public string? Path { get; set; } + + /// + /// 前端组件路径 + /// + public string? Component { get; set; } + + /// + /// 菜单图标 + /// + public string? Icon { get; set; } + + /// + /// 排序值 + /// + public int Sort { get; set; } + + /// + /// 是否显示在菜单 + /// + public bool IsVisible { get; set; } + + /// + /// 状态 + /// + public int Status { get; set; } + + /// + /// 子菜单 + /// + public List Children { get; set; } = []; +} + +/// +/// 后台菜单查询输入 +/// +public class AdminMenuQueryInput +{ + /// + /// 菜单名称 + /// + public string? Name { get; set; } + + /// + /// 权限编码 + /// + public string? Code { get; set; } + + /// + /// 状态 + /// + public int? Status { get; set; } +} + +/// +/// 后台角色输入 +/// +public class AdminRoleInput +{ + /// + /// 角色名称 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 角色编码 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 备注 + /// + public string? Remark { get; set; } + + /// + /// 状态 + /// + public int Status { get; set; } = 1; + + /// + /// 菜单ID集合 + /// + public List MenuIds { get; set; } = []; +} + +/// +/// 后台角色输出 +/// +public class AdminRoleOutput +{ + /// + /// 角色ID + /// + public long Id { get; set; } + + /// + /// 角色名称 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 角色编码 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 备注 + /// + public string? Remark { get; set; } + + /// + /// 状态 + /// + public int Status { get; set; } + + /// + /// 菜单ID集合 + /// + public List MenuIds { get; set; } = []; + + /// + /// 创建时间 + /// + public DateTime CreatedAt { get; set; } +} + +/// +/// 后台角色简要输出 +/// +public class AdminRoleSimpleOutput +{ + /// + /// 角色ID + /// + public long Id { get; set; } + + /// + /// 角色名称 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 角色编码 + /// + public string Code { get; set; } = string.Empty; +} + +/// +/// 后台角色查询输入 +/// +public class AdminRoleQueryInput : PageQueryModel +{ + /// + /// 角色名称 + /// + public string? Name { get; set; } + + /// + /// 角色编码 + /// + public string? Code { get; set; } + + /// + /// 状态 + /// + public int? Status { get; set; } +} + +/// +/// 分配角色菜单输入 +/// +public class AssignRoleMenusInput +{ + /// + /// 菜单ID集合 + /// + public List MenuIds { get; set; } = []; +} + +/// +/// 分配用户角色输入 +/// +public class AssignAdminUserRolesInput +{ + /// + /// 角色ID集合 + /// + public List RoleIds { get; set; } = []; +} diff --git a/QYZH.InteractiveMagazine.Models/Dto/Admin/AdminUserDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Admin/AdminUserDto.cs index 8186c97..4df5a7e 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Admin/AdminUserDto.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Admin/AdminUserDto.cs @@ -27,6 +27,11 @@ public class AdminUserInput /// 状态: Active, Inactive /// public int Status { get; set; } = 1; + + /// + /// 角色ID集合,更新时为null表示不调整角色 + /// + public List? RoleIds { get; set; } } /// @@ -73,6 +78,16 @@ public class AdminUserOutput /// 更新时间 /// public DateTime? UpdatedAt { get; set; } + + /// + /// 角色ID集合 + /// + public List RoleIds { get; set; } = []; + + /// + /// 角色集合 + /// + public List Roles { get; set; } = []; } /// diff --git a/QYZH.InteractiveMagazine.Models/Dto/Admin/AuthDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Admin/AuthDto.cs index 1335868..640ec76 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Admin/AuthDto.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Admin/AuthDto.cs @@ -26,6 +26,26 @@ public class AdminLoginOutput public string UserName { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; + + /// + /// 角色ID集合 + /// + public List RoleIds { get; set; } = []; + + /// + /// 角色集合 + /// + public List Roles { get; set; } = []; + + /// + /// 菜单树 + /// + public List Menus { get; set; } = []; + + /// + /// 权限编码集合 + /// + public List PermissionCodes { get; set; } = []; } public class AdminUserInfoOutput @@ -37,4 +57,24 @@ public class AdminUserInfoOutput public string Type { get; set; } = string.Empty; public int Status { get; set; } + + /// + /// 角色ID集合 + /// + public List RoleIds { get; set; } = []; + + /// + /// 角色集合 + /// + public List Roles { get; set; } = []; + + /// + /// 菜单树 + /// + public List Menus { get; set; } = []; + + /// + /// 权限编码集合 + /// + public List PermissionCodes { get; set; } = []; } diff --git a/QYZH.InteractiveMagazine.Models/Entity/AdminMenu.cs b/QYZH.InteractiveMagazine.Models/Entity/AdminMenu.cs new file mode 100644 index 0000000..8e033b6 --- /dev/null +++ b/QYZH.InteractiveMagazine.Models/Entity/AdminMenu.cs @@ -0,0 +1,66 @@ +using SqlSugar; + +namespace QYZH.InteractiveMagazine.Models.Entity; + +/// +/// 后台菜单表 +/// +[SugarTable("AdminMenu")] +public partial class AdminMenu : SqlSugarBaseEntity +{ + /// + /// Desc:父级菜单ID,根节点为0 + /// Default:0 + /// Nullable:False + /// + public long ParentId { get; set; } + + /// + /// Desc:菜单名称 + /// Default: + /// Nullable:False + /// + public string Name { get; set; } = string.Empty; + + /// + /// Desc:权限编码 + /// Default: + /// Nullable:False + /// + public string Code { get; set; } = string.Empty; + + /// + /// Desc:前端路由路径 + /// Default: + /// Nullable:True + /// + public string? Path { get; set; } + + /// + /// Desc:前端组件路径 + /// Default: + /// Nullable:True + /// + public string? Component { get; set; } + + /// + /// Desc:菜单图标 + /// Default: + /// Nullable:True + /// + public string? Icon { get; set; } + + /// + /// Desc:排序值 + /// Default:0 + /// Nullable:False + /// + public int Sort { get; set; } + + /// + /// Desc:是否显示在菜单 + /// Default:b'1' + /// Nullable:False + /// + public bool IsVisible { get; set; } = true; +} diff --git a/QYZH.InteractiveMagazine.Models/Entity/AdminRole.cs b/QYZH.InteractiveMagazine.Models/Entity/AdminRole.cs new file mode 100644 index 0000000..9ffd19a --- /dev/null +++ b/QYZH.InteractiveMagazine.Models/Entity/AdminRole.cs @@ -0,0 +1,31 @@ +using SqlSugar; + +namespace QYZH.InteractiveMagazine.Models.Entity; + +/// +/// 后台角色表 +/// +[SugarTable("AdminRole")] +public partial class AdminRole : SqlSugarBaseEntity +{ + /// + /// Desc:角色名称 + /// Default: + /// Nullable:False + /// + public string Name { get; set; } = string.Empty; + + /// + /// Desc:角色编码 + /// Default: + /// Nullable:False + /// + public string Code { get; set; } = string.Empty; + + /// + /// Desc:备注 + /// Default: + /// Nullable:True + /// + public string? Remark { get; set; } +} diff --git a/QYZH.InteractiveMagazine.Models/Entity/AdminRoleMenu.cs b/QYZH.InteractiveMagazine.Models/Entity/AdminRoleMenu.cs new file mode 100644 index 0000000..b5d06ac --- /dev/null +++ b/QYZH.InteractiveMagazine.Models/Entity/AdminRoleMenu.cs @@ -0,0 +1,24 @@ +using SqlSugar; + +namespace QYZH.InteractiveMagazine.Models.Entity; + +/// +/// 后台角色菜单关系表 +/// +[SugarTable("AdminRoleMenu")] +public partial class AdminRoleMenu : SqlSugarBaseEntity +{ + /// + /// Desc:角色ID + /// Default: + /// Nullable:False + /// + public long RoleId { get; set; } + + /// + /// Desc:菜单ID + /// Default: + /// Nullable:False + /// + public long MenuId { get; set; } +} diff --git a/QYZH.InteractiveMagazine.Models/Entity/AdminUserRole.cs b/QYZH.InteractiveMagazine.Models/Entity/AdminUserRole.cs new file mode 100644 index 0000000..4399da0 --- /dev/null +++ b/QYZH.InteractiveMagazine.Models/Entity/AdminUserRole.cs @@ -0,0 +1,24 @@ +using SqlSugar; + +namespace QYZH.InteractiveMagazine.Models.Entity; + +/// +/// 后台管理员角色关系表 +/// +[SugarTable("AdminUserRole")] +public partial class AdminUserRole : SqlSugarBaseEntity +{ + /// + /// Desc:管理员ID + /// Default: + /// Nullable:False + /// + public long AdminUserId { get; set; } + + /// + /// Desc:角色ID + /// Default: + /// Nullable:False + /// + public long RoleId { get; set; } +} diff --git a/QYZH.InteractiveMagazine.Service/AdminAuthService.cs b/QYZH.InteractiveMagazine.Service/AdminAuthService.cs index 1aaa911..0ae9d13 100644 --- a/QYZH.InteractiveMagazine.Service/AdminAuthService.cs +++ b/QYZH.InteractiveMagazine.Service/AdminAuthService.cs @@ -12,7 +12,11 @@ using QYZH.InteractiveMagazine.Repository; namespace QYZH.InteractiveMagazine.Service; -public class AdminAuthService(BaseRepository adminUserRepository, IConfiguration configuration, ILogger logger) : BaseRepository, IAdminAuthService +public class AdminAuthService( + BaseRepository adminUserRepository, + IAdminPermissionService adminPermissionService, + IConfiguration configuration, + ILogger logger) : BaseRepository, IAdminAuthService { private const string TokenKeyPrefix = "InteractiveMagazine:AdminAuth:Token"; @@ -60,12 +64,20 @@ public class AdminAuthService(BaseRepository adminUserRepository, ICo logger.LogInformation("管理员登录成功,用户名: {UserName}, ID: {UserId}", input.UserName, adminUser.Id); + var roles = await adminPermissionService.GetAdminUserRolesAsync(adminUser.Id); + var menus = await adminPermissionService.GetAdminUserMenuTreeAsync(adminUser.Id); + var permissionCodes = await adminPermissionService.GetAdminUserPermissionCodesAsync(adminUser.Id); + return new AdminLoginOutput { Token = token, UserId = (long)adminUser.Id, UserName = adminUser.UserName, Type = adminUser.Type.ToString(), + RoleIds = roles.Select(x => x.Id).ToList(), + Roles = roles, + Menus = menus, + PermissionCodes = permissionCodes }; } @@ -89,12 +101,20 @@ public class AdminAuthService(BaseRepository adminUserRepository, ICo throw new BusinessException("用户不存在", ResultCode.NOT_FOUND); } + var roles = await adminPermissionService.GetAdminUserRolesAsync(adminUser.Id); + var menus = await adminPermissionService.GetAdminUserMenuTreeAsync(adminUser.Id); + var permissionCodes = await adminPermissionService.GetAdminUserPermissionCodesAsync(adminUser.Id); + return new AdminUserInfoOutput { UserId = adminUser.Id, UserName = adminUser.UserName, Type = adminUser.Type.ToString(), - Status = adminUser.Status + Status = adminUser.Status, + RoleIds = roles.Select(x => x.Id).ToList(), + Roles = roles, + Menus = menus, + PermissionCodes = permissionCodes }; } diff --git a/QYZH.InteractiveMagazine.Service/AdminPermissionService.cs b/QYZH.InteractiveMagazine.Service/AdminPermissionService.cs new file mode 100644 index 0000000..edb804b --- /dev/null +++ b/QYZH.InteractiveMagazine.Service/AdminPermissionService.cs @@ -0,0 +1,495 @@ +using Microsoft.Extensions.Logging; +using QYZH.InteractiveMagazine.IService; +using QYZH.InteractiveMagazine.Models.Common; +using QYZH.InteractiveMagazine.Models.Dto; +using QYZH.InteractiveMagazine.Models.Entity; +using QYZH.InteractiveMagazine.Models.Enum; +using QYZH.InteractiveMagazine.Repository; +using SqlSugar; + +namespace QYZH.InteractiveMagazine.Service; + +/// +/// 后台权限管理服务实现 +/// +public class AdminPermissionService( + BaseRepository adminRoleRepository, + BaseRepository adminMenuRepository, + BaseRepository adminRoleMenuRepository, + BaseRepository adminUserRoleRepository, + BaseRepository adminUserRepository, + ILogger logger) : BaseRepository, IAdminPermissionService +{ + /// + /// 创建菜单 + /// + public async Task CreateMenuAsync(AdminMenuInput input) + { + await ValidateMenuInputAsync(input); + + var menu = new AdminMenu + { + ParentId = input.ParentId, + Name = input.Name.Trim(), + Code = input.Code.Trim(), + Path = input.Path?.Trim(), + Component = input.Component?.Trim(), + Icon = input.Icon?.Trim(), + Sort = input.Sort, + IsVisible = input.IsVisible, + Status = input.Status, + CreatedBy = "System", + CreatedAt = DateTime.Now, + UpdatedBy = "System", + UpdatedAt = DateTime.Now, + IsDeleted = false + }; + + var result = await adminMenuRepository.InsertAsync(menu); + BusinessException.ThrowIf(!result, "创建菜单失败", ResultCode.GLOBAL_ERROR); + + logger.LogInformation("创建后台菜单成功:{Code},ID:{Id}", menu.Code, menu.Id); + return ToMenuOutput(menu); + } + + /// + /// 更新菜单 + /// + public async Task UpdateMenuAsync(long id, AdminMenuInput input) + { + var menu = await adminMenuRepository.GetByIdAsync(id); + BusinessException.ThrowIf(menu == null, "菜单不存在", ResultCode.NOT_FOUND); + BusinessException.ThrowIf(input.ParentId == id, "父级菜单不能选择自身", ResultCode.BAD_REQUEST); + + await ValidateMenuInputAsync(input, id); + + menu!.ParentId = input.ParentId; + menu.Name = input.Name.Trim(); + menu.Code = input.Code.Trim(); + menu.Path = input.Path?.Trim(); + menu.Component = input.Component?.Trim(); + menu.Icon = input.Icon?.Trim(); + menu.Sort = input.Sort; + menu.IsVisible = input.IsVisible; + menu.Status = input.Status; + menu.UpdatedBy = "System"; + menu.UpdatedAt = DateTime.Now; + + var result = await adminMenuRepository.UpdateAsync(menu); + BusinessException.ThrowIf(!result, "更新菜单失败", ResultCode.GLOBAL_ERROR); + + logger.LogInformation("更新后台菜单成功:{Code},ID:{Id}", menu.Code, menu.Id); + return ToMenuOutput(menu); + } + + /// + /// 删除菜单 + /// + public async Task DeleteMenuAsync(long id) + { + var menu = await adminMenuRepository.GetByIdAsync(id); + BusinessException.ThrowIf(menu == null, "菜单不存在", ResultCode.NOT_FOUND); + + var hasChildren = await adminMenuRepository.Queryable().AnyAsync(x => x.ParentId == id && !x.IsDeleted); + BusinessException.ThrowIf(hasChildren, "请先删除子菜单", ResultCode.CONFLICT); + + var usedByRole = await adminRoleMenuRepository.Queryable().AnyAsync(x => x.MenuId == id && !x.IsDeleted); + BusinessException.ThrowIf(usedByRole, "菜单已被角色使用,不能删除", ResultCode.CONFLICT); + + var result = await adminMenuRepository.DeleteByIdAsync(id); + BusinessException.ThrowIf(!result, "删除菜单失败", ResultCode.GLOBAL_ERROR); + } + + /// + /// 获取菜单树 + /// + public async Task> GetMenuTreeAsync(AdminMenuQueryInput input) + { + var menus = await adminMenuRepository.Queryable() + .Where(x => !x.IsDeleted) + .WhereIF(!string.IsNullOrWhiteSpace(input.Name), x => x.Name.Contains(input.Name!)) + .WhereIF(!string.IsNullOrWhiteSpace(input.Code), x => x.Code.Contains(input.Code!)) + .WhereIF(input.Status.HasValue, x => x.Status == input.Status!.Value) + .OrderBy(x => x.Sort) + .OrderBy(x => x.Id) + .ToListAsync(); + + return BuildMenuTree(menus); + } + + /// + /// 创建角色 + /// + public async Task CreateRoleAsync(AdminRoleInput input) + { + await ValidateRoleInputAsync(input); + + var role = new AdminRole + { + Name = input.Name.Trim(), + Code = input.Code.Trim(), + Remark = input.Remark?.Trim(), + Status = input.Status, + CreatedBy = "System", + CreatedAt = DateTime.Now, + UpdatedBy = "System", + UpdatedAt = DateTime.Now, + IsDeleted = false + }; + + await UseTranAsync(async () => + { + var inserted = await adminRoleRepository.InsertAsync(role); + BusinessException.ThrowIf(!inserted, "创建角色失败", ResultCode.GLOBAL_ERROR); + await ReplaceRoleMenusAsync(role.Id, input.MenuIds); + }); + + logger.LogInformation("创建后台角色成功:{Code},ID:{Id}", role.Code, role.Id); + return await GetRoleByIdAsync(role.Id); + } + + /// + /// 更新角色 + /// + public async Task UpdateRoleAsync(long id, AdminRoleInput input) + { + var role = await adminRoleRepository.GetByIdAsync(id); + BusinessException.ThrowIf(role == null, "角色不存在", ResultCode.NOT_FOUND); + + await ValidateRoleInputAsync(input, id); + + role!.Name = input.Name.Trim(); + role.Code = input.Code.Trim(); + role.Remark = input.Remark?.Trim(); + role.Status = input.Status; + role.UpdatedBy = "System"; + role.UpdatedAt = DateTime.Now; + + await UseTranAsync(async () => + { + var updated = await adminRoleRepository.UpdateAsync(role); + BusinessException.ThrowIf(!updated, "更新角色失败", ResultCode.GLOBAL_ERROR); + await ReplaceRoleMenusAsync(role.Id, input.MenuIds); + }); + + logger.LogInformation("更新后台角色成功:{Code},ID:{Id}", role.Code, role.Id); + return await GetRoleByIdAsync(role.Id); + } + + /// + /// 删除角色 + /// + public async Task DeleteRoleAsync(long id) + { + var role = await adminRoleRepository.GetByIdAsync(id); + BusinessException.ThrowIf(role == null, "角色不存在", ResultCode.NOT_FOUND); + + var usedByUser = await adminUserRoleRepository.Queryable().AnyAsync(x => x.RoleId == id && !x.IsDeleted); + BusinessException.ThrowIf(usedByUser, "角色已分配给管理员,不能删除", ResultCode.CONFLICT); + + await UseTranAsync(async () => + { + await adminRoleMenuRepository.Deleteable().Where(x => x.RoleId == id).ExecuteCommandAsync(); + var deleted = await adminRoleRepository.DeleteByIdAsync(id); + BusinessException.ThrowIf(!deleted, "删除角色失败", ResultCode.GLOBAL_ERROR); + }); + } + + /// + /// 获取角色详情 + /// + public async Task GetRoleByIdAsync(long id) + { + var role = await adminRoleRepository.GetByIdAsync(id); + BusinessException.ThrowIf(role == null, "角色不存在", ResultCode.NOT_FOUND); + + var menuIds = await adminRoleMenuRepository.Queryable() + .Where(x => x.RoleId == id && !x.IsDeleted) + .Select(x => x.MenuId) + .ToListAsync(); + + return ToRoleOutput(role!, menuIds); + } + + /// + /// 获取角色分页列表 + /// + public async Task> GetRoleListAsync(AdminRoleQueryInput input) + { + RefAsync totalNumber = 0; + var roles = await adminRoleRepository.Queryable() + .Where(x => !x.IsDeleted) + .WhereIF(!string.IsNullOrWhiteSpace(input.Name), x => x.Name.Contains(input.Name!)) + .WhereIF(!string.IsNullOrWhiteSpace(input.Code), x => x.Code.Contains(input.Code!)) + .WhereIF(input.Status.HasValue, x => x.Status == input.Status!.Value) + .OrderByDescending(x => x.CreatedAt) + .ToPageListAsync(input.PageIndex, input.PageSize, totalNumber); + + var roleIds = roles.Select(x => x.Id).ToList(); + var roleMenus = roleIds.Count == 0 + ? [] + : await adminRoleMenuRepository.Queryable() + .Where(x => roleIds.Contains(x.RoleId) && !x.IsDeleted) + .ToListAsync(); + + var outputs = roles + .Select(x => ToRoleOutput(x, roleMenus.Where(rm => rm.RoleId == x.Id).Select(rm => rm.MenuId).ToList())) + .ToList(); + + return new PageListModel(outputs, input.PageIndex, input.PageSize, totalNumber); + } + + /// + /// 分配角色菜单 + /// + public async Task AssignRoleMenusAsync(long roleId, AssignRoleMenusInput input) + { + var role = await adminRoleRepository.GetByIdAsync(roleId); + BusinessException.ThrowIf(role == null, "角色不存在", ResultCode.NOT_FOUND); + + await ValidateMenuIdsAsync(input.MenuIds); + await UseTranAsync(async () => await ReplaceRoleMenusAsync(roleId, input.MenuIds)); + } + + /// + /// 分配管理员角色 + /// + public async Task AssignAdminUserRolesAsync(long adminUserId, AssignAdminUserRolesInput input) + { + await ReplaceAdminUserRolesAsync(adminUserId, input.RoleIds); + } + + /// + /// 获取管理员菜单树 + /// + public async Task> GetAdminUserMenuTreeAsync(long adminUserId) + { + var menus = await GetAdminUserMenusAsync(adminUserId, true); + return BuildMenuTree(menus); + } + + /// + /// 获取管理员角色 + /// + public async Task> GetAdminUserRolesAsync(long adminUserId) + { + var adminUser = await adminUserRepository.GetByIdAsync(adminUserId); + BusinessException.ThrowIf(adminUser == null, "管理员不存在", ResultCode.NOT_FOUND); + + return await adminRoleRepository.Queryable() + .InnerJoin((role, userRole) => role.Id == userRole.RoleId) + .Where((role, userRole) => userRole.AdminUserId == adminUserId && !role.IsDeleted && !userRole.IsDeleted) + .Select((role, userRole) => new AdminRoleSimpleOutput + { + Id = role.Id, + Name = role.Name, + Code = role.Code + }) + .ToListAsync(); + } + + /// + /// 获取管理员权限编码 + /// + public async Task> GetAdminUserPermissionCodesAsync(long adminUserId) + { + var menus = await GetAdminUserMenusAsync(adminUserId, false); + return menus.Select(x => x.Code).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); + } + + private async Task ValidateMenuInputAsync(AdminMenuInput input, long? id = null) + { + BusinessException.ThrowIf(string.IsNullOrWhiteSpace(input.Name), "菜单名称不能为空", ResultCode.BAD_REQUEST); + BusinessException.ThrowIf(string.IsNullOrWhiteSpace(input.Code), "权限编码不能为空", ResultCode.BAD_REQUEST); + + if (input.ParentId > 0) + { + var parentExists = await adminMenuRepository.Queryable().AnyAsync(x => x.Id == input.ParentId && !x.IsDeleted); + BusinessException.ThrowIf(!parentExists, "父级菜单不存在", ResultCode.NOT_FOUND); + } + + var code = input.Code.Trim(); + var codeExists = await adminMenuRepository.Queryable() + .AnyAsync(x => x.Code == code && !x.IsDeleted && (!id.HasValue || x.Id != id.Value)); + BusinessException.ThrowIf(codeExists, "权限编码已存在", ResultCode.CONFLICT); + } + + private async Task ValidateRoleInputAsync(AdminRoleInput input, long? id = null) + { + BusinessException.ThrowIf(string.IsNullOrWhiteSpace(input.Name), "角色名称不能为空", ResultCode.BAD_REQUEST); + BusinessException.ThrowIf(string.IsNullOrWhiteSpace(input.Code), "角色编码不能为空", ResultCode.BAD_REQUEST); + + var code = input.Code.Trim(); + var codeExists = await adminRoleRepository.Queryable() + .AnyAsync(x => x.Code == code && !x.IsDeleted && (!id.HasValue || x.Id != id.Value)); + BusinessException.ThrowIf(codeExists, "角色编码已存在", ResultCode.CONFLICT); + + await ValidateMenuIdsAsync(input.MenuIds); + } + + private async Task ValidateMenuIdsAsync(List menuIds) + { + var ids = menuIds.Distinct().ToList(); + if (ids.Count == 0) + { + return; + } + + var existsCount = await adminMenuRepository.Queryable() + .Where(x => ids.Contains(x.Id) && !x.IsDeleted) + .CountAsync(); + BusinessException.ThrowIf(existsCount != ids.Count, "包含不存在的菜单", ResultCode.BAD_REQUEST); + } + + private async Task ValidateRoleIdsAsync(List roleIds) + { + var ids = roleIds.Distinct().ToList(); + if (ids.Count == 0) + { + return; + } + + var existsCount = await adminRoleRepository.Queryable() + .Where(x => ids.Contains(x.Id) && !x.IsDeleted) + .CountAsync(); + BusinessException.ThrowIf(existsCount != ids.Count, "包含不存在的角色", ResultCode.BAD_REQUEST); + } + + private async Task ReplaceRoleMenusAsync(long roleId, List menuIds) + { + await adminRoleMenuRepository.Deleteable().Where(x => x.RoleId == roleId).ExecuteCommandAsync(); + + var now = DateTime.Now; + var items = menuIds.Distinct().Select(menuId => new AdminRoleMenu + { + RoleId = roleId, + MenuId = menuId, + Status = (int)DefaultStatusEnum.Active, + CreatedBy = "System", + CreatedAt = now, + UpdatedBy = "System", + UpdatedAt = now, + IsDeleted = false + }).ToList(); + + if (items.Count > 0) + { + await adminRoleMenuRepository.Context.Insertable(items).ExecuteCommandAsync(); + } + } + + private async Task ReplaceAdminUserRolesAsync(long adminUserId, List roleIds) + { + var adminUser = await adminUserRepository.GetByIdAsync(adminUserId); + BusinessException.ThrowIf(adminUser == null, "管理员不存在", ResultCode.NOT_FOUND); + + await ValidateRoleIdsAsync(roleIds); + + await UseTranAsync(async () => + { + await adminUserRoleRepository.Deleteable().Where(x => x.AdminUserId == adminUserId).ExecuteCommandAsync(); + + var now = DateTime.Now; + var items = roleIds.Distinct().Select(roleId => new AdminUserRole + { + AdminUserId = adminUserId, + RoleId = roleId, + Status = (int)DefaultStatusEnum.Active, + CreatedBy = "System", + CreatedAt = now, + UpdatedBy = "System", + UpdatedAt = now, + IsDeleted = false + }).ToList(); + + if (items.Count > 0) + { + await adminUserRoleRepository.Context.Insertable(items).ExecuteCommandAsync(); + } + }); + } + + private async Task> GetAdminUserMenusAsync(long adminUserId, bool visibleOnly) + { + var adminUser = await adminUserRepository.GetByIdAsync(adminUserId); + BusinessException.ThrowIf(adminUser == null, "管理员不存在", ResultCode.NOT_FOUND); + + if (adminUser!.Type == AdminUserTypeEnum.SuperAdmin) + { + return await adminMenuRepository.Queryable() + .Where(x => !x.IsDeleted && x.Status == (int)DefaultStatusEnum.Active) + .WhereIF(visibleOnly, x => x.IsVisible) + .OrderBy(x => x.Sort) + .OrderBy(x => x.Id) + .ToListAsync(); + } + + var menus = await adminMenuRepository.Queryable() + .InnerJoin((menu, roleMenu) => menu.Id == roleMenu.MenuId) + .InnerJoin((menu, roleMenu, role) => roleMenu.RoleId == role.Id) + .InnerJoin((menu, roleMenu, role, userRole) => role.Id == userRole.RoleId) + .Where((menu, roleMenu, role, userRole) => + userRole.AdminUserId == adminUserId + && !menu.IsDeleted + && !roleMenu.IsDeleted + && !role.IsDeleted + && !userRole.IsDeleted + && menu.Status == (int)DefaultStatusEnum.Active + && role.Status == (int)DefaultStatusEnum.Active) + .WhereIF(visibleOnly, (menu, roleMenu, role, userRole) => menu.IsVisible) + .OrderBy((menu, roleMenu, role, userRole) => menu.Sort) + .OrderBy((menu, roleMenu, role, userRole) => menu.Id) + .Select((menu, roleMenu, role, userRole) => menu) + .ToListAsync(); + + return menus.DistinctBy(x => x.Id).OrderBy(x => x.Sort).ThenBy(x => x.Id).ToList(); + } + + private static List BuildMenuTree(List menus) + { + var outputs = menus.Select(ToMenuOutput).ToList(); + var lookup = outputs.ToLookup(x => x.ParentId); + + foreach (var item in outputs) + { + item.Children = lookup[item.Id].OrderBy(x => x.Sort).ThenBy(x => x.Id).ToList(); + } + + return outputs + .Where(x => x.ParentId == 0 || outputs.All(item => item.Id != x.ParentId)) + .OrderBy(x => x.Sort) + .ThenBy(x => x.Id) + .ToList(); + } + + private static AdminMenuOutput ToMenuOutput(AdminMenu menu) + { + return new AdminMenuOutput + { + Id = menu.Id, + ParentId = menu.ParentId, + Name = menu.Name, + Code = menu.Code, + Path = menu.Path, + Component = menu.Component, + Icon = menu.Icon, + Sort = menu.Sort, + IsVisible = menu.IsVisible, + Status = menu.Status + }; + } + + private static AdminRoleOutput ToRoleOutput(AdminRole role, List menuIds) + { + return new AdminRoleOutput + { + Id = role.Id, + Name = role.Name, + Code = role.Code, + Remark = role.Remark, + Status = role.Status, + MenuIds = menuIds, + CreatedAt = role.CreatedAt + }; + } +} diff --git a/QYZH.InteractiveMagazine.Service/AdminUserService.cs b/QYZH.InteractiveMagazine.Service/AdminUserService.cs index 9b61b01..bd5731e 100644 --- a/QYZH.InteractiveMagazine.Service/AdminUserService.cs +++ b/QYZH.InteractiveMagazine.Service/AdminUserService.cs @@ -15,7 +15,10 @@ namespace QYZH.InteractiveMagazine.Service; /// /// 管理员用户服务实现 /// -public class AdminUserService(BaseRepository adminUserRepository, ILogger logger) : BaseRepository, IAdminUserService +public class AdminUserService( + BaseRepository adminUserRepository, + IAdminPermissionService adminPermissionService, + ILogger logger) : BaseRepository, IAdminUserService { /// @@ -63,9 +66,14 @@ public class AdminUserService(BaseRepository adminUserRepository, ILo throw new BusinessException("创建管理员失败", ResultCode.GLOBAL_ERROR); } + if (input.RoleIds != null) + { + await adminPermissionService.AssignAdminUserRolesAsync(adminUser.Id, new AssignAdminUserRolesInput { RoleIds = input.RoleIds }); + } + logger.LogInformation("管理员创建成功,用户名: {UserName}, ID: {Id}", input.UserName, adminUser.Id); - return new AdminUserOutput { Id = adminUser.Id, UserName = adminUser.UserName, Type = adminUser.Type.ToString(), Status = adminUser.Status, CreatedBy = adminUser.CreatedBy, CreatedAt = adminUser.CreatedAt, UpdatedBy = adminUser.UpdatedBy, UpdatedAt = adminUser.UpdatedAt }; + return await ToAdminUserOutputAsync(adminUser); } /// @@ -114,9 +122,14 @@ public class AdminUserService(BaseRepository adminUserRepository, ILo throw new BusinessException("更新管理员失败", ResultCode.GLOBAL_ERROR); } + if (input.RoleIds != null) + { + await adminPermissionService.AssignAdminUserRolesAsync(adminUser.Id, new AssignAdminUserRolesInput { RoleIds = input.RoleIds }); + } + logger.LogInformation("管理员更新成功,ID: {Id}", id); - return new AdminUserOutput { Id = adminUser.Id, UserName = adminUser.UserName, Type = adminUser.Type.ToString(), Status = adminUser.Status, CreatedBy = adminUser.CreatedBy, CreatedAt = adminUser.CreatedAt, UpdatedBy = adminUser.UpdatedBy, UpdatedAt = adminUser.UpdatedAt }; + return await ToAdminUserOutputAsync(adminUser); } /// @@ -156,17 +169,7 @@ public class AdminUserService(BaseRepository adminUserRepository, ILo logger.LogWarning("未找到管理员,ID: {Id}", id); throw new BusinessException("管理员不存在", ResultCode.NOT_FOUND); } - return new AdminUserOutput - { - Id = adminUser.Id, - UserName = adminUser.UserName, - Type = adminUser.Type.ToString(), - Status = adminUser.Status, - CreatedBy = adminUser.CreatedBy, - CreatedAt = adminUser.CreatedAt, - UpdatedBy = adminUser.UpdatedBy, - UpdatedAt = adminUser.UpdatedAt - }; + return await ToAdminUserOutputAsync(adminUser); } /// @@ -201,6 +204,12 @@ public class AdminUserService(BaseRepository adminUserRepository, ILo UpdatedAt = a.UpdatedAt }, true) .ToPageListAsync(input.PageIndex, input.PageSize, totalNumber); + foreach (var item in pageResult) + { + item.Roles = await adminPermissionService.GetAdminUserRolesAsync(item.Id); + item.RoleIds = item.Roles.Select(x => x.Id).ToList(); + } + return new PageListModel(pageResult, input.PageIndex, input.PageSize, totalNumber); } @@ -231,4 +240,22 @@ public class AdminUserService(BaseRepository adminUserRepository, ILo logger.LogInformation("管理员状态更新成功,ID: {Id}", id); } + + private async Task ToAdminUserOutputAsync(AdminUser adminUser) + { + var roles = await adminPermissionService.GetAdminUserRolesAsync(adminUser.Id); + return new AdminUserOutput + { + Id = adminUser.Id, + UserName = adminUser.UserName, + Type = adminUser.Type.ToString(), + Status = adminUser.Status, + CreatedBy = adminUser.CreatedBy, + CreatedAt = adminUser.CreatedAt, + UpdatedBy = adminUser.UpdatedBy, + UpdatedAt = adminUser.UpdatedAt, + RoleIds = roles.Select(x => x.Id).ToList(), + Roles = roles + }; + } } diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/BagController.cs b/QYZH.InteractiveMagazine.WeChatApi/Controllers/BagController.cs similarity index 96% rename from QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/BagController.cs rename to QYZH.InteractiveMagazine.WeChatApi/Controllers/BagController.cs index 8133d3b..024d48b 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/BagController.cs +++ b/QYZH.InteractiveMagazine.WeChatApi/Controllers/BagController.cs @@ -3,7 +3,7 @@ using QYZH.InteractiveMagazine.IService; using QYZH.InteractiveMagazine.Models.Dto; using QYZH.InteractiveMagazine.Models.Dto.Bag; -namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat; +namespace QYZH.InteractiveMagazine.WeChatApi.Controllers; /// /// 小程序背包控制器 diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/CheckInController.cs b/QYZH.InteractiveMagazine.WeChatApi/Controllers/CheckInController.cs similarity index 98% rename from QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/CheckInController.cs rename to QYZH.InteractiveMagazine.WeChatApi/Controllers/CheckInController.cs index a03ee23..3c4938c 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/CheckInController.cs +++ b/QYZH.InteractiveMagazine.WeChatApi/Controllers/CheckInController.cs @@ -4,7 +4,7 @@ using QYZH.InteractiveMagazine.Models.Common; using QYZH.InteractiveMagazine.Models.Dto; using QYZH.InteractiveMagazine.Models.Dto.CheckIn; -namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat; +namespace QYZH.InteractiveMagazine.WeChatApi.Controllers; /// /// 签到控制器 diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/CommunityController.cs b/QYZH.InteractiveMagazine.WeChatApi/Controllers/CommunityController.cs similarity index 98% rename from QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/CommunityController.cs rename to QYZH.InteractiveMagazine.WeChatApi/Controllers/CommunityController.cs index 8ca4973..d81be05 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/CommunityController.cs +++ b/QYZH.InteractiveMagazine.WeChatApi/Controllers/CommunityController.cs @@ -3,7 +3,7 @@ using QYZH.InteractiveMagazine.IService; using QYZH.InteractiveMagazine.Models.Common; using QYZH.InteractiveMagazine.Models.Dto; -namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat; +namespace QYZH.InteractiveMagazine.WeChatApi.Controllers; /// /// 小程序社区控制器 diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/JournalController.cs b/QYZH.InteractiveMagazine.WeChatApi/Controllers/JournalController.cs similarity index 98% rename from QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/JournalController.cs rename to QYZH.InteractiveMagazine.WeChatApi/Controllers/JournalController.cs index 44e260e..ec8d514 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/JournalController.cs +++ b/QYZH.InteractiveMagazine.WeChatApi/Controllers/JournalController.cs @@ -3,7 +3,7 @@ using QYZH.InteractiveMagazine.IService; using QYZH.InteractiveMagazine.Models.Common; using QYZH.InteractiveMagazine.Models.Dto; -namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat; +namespace QYZH.InteractiveMagazine.WeChatApi.Controllers; /// /// 小程序期刊管理控制器 diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/MallController.cs b/QYZH.InteractiveMagazine.WeChatApi/Controllers/MallController.cs similarity index 97% rename from QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/MallController.cs rename to QYZH.InteractiveMagazine.WeChatApi/Controllers/MallController.cs index 90e2f77..6f97519 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/MallController.cs +++ b/QYZH.InteractiveMagazine.WeChatApi/Controllers/MallController.cs @@ -3,7 +3,7 @@ using QYZH.InteractiveMagazine.IService; using QYZH.InteractiveMagazine.Models.Dto; using QYZH.InteractiveMagazine.Models.Dto.Mall; -namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat; +namespace QYZH.InteractiveMagazine.WeChatApi.Controllers; /// /// 小程序商城控制器 diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/MedalController.cs b/QYZH.InteractiveMagazine.WeChatApi/Controllers/MedalController.cs similarity index 98% rename from QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/MedalController.cs rename to QYZH.InteractiveMagazine.WeChatApi/Controllers/MedalController.cs index 42ffac6..2819ae3 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/MedalController.cs +++ b/QYZH.InteractiveMagazine.WeChatApi/Controllers/MedalController.cs @@ -3,7 +3,7 @@ using QYZH.InteractiveMagazine.IService; using QYZH.InteractiveMagazine.Models.Common; using QYZH.InteractiveMagazine.Models.Dto; -namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat; +namespace QYZH.InteractiveMagazine.WeChatApi.Controllers; /// /// 小程序勋章控制器 diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/PetController.cs b/QYZH.InteractiveMagazine.WeChatApi/Controllers/PetController.cs similarity index 98% rename from QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/PetController.cs rename to QYZH.InteractiveMagazine.WeChatApi/Controllers/PetController.cs index 894f79b..8e7b35e 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/PetController.cs +++ b/QYZH.InteractiveMagazine.WeChatApi/Controllers/PetController.cs @@ -4,7 +4,7 @@ using QYZH.InteractiveMagazine.Models.Common; using QYZH.InteractiveMagazine.Models.Dto; using QYZH.InteractiveMagazine.Models.Dto.Pet; -namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat; +namespace QYZH.InteractiveMagazine.WeChatApi.Controllers; /// /// 小程序宠物管理控制器 diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs b/QYZH.InteractiveMagazine.WeChatApi/Controllers/UserAnswerTaskController.cs similarity index 99% rename from QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs rename to QYZH.InteractiveMagazine.WeChatApi/Controllers/UserAnswerTaskController.cs index 62e2211..c87c2fb 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs +++ b/QYZH.InteractiveMagazine.WeChatApi/Controllers/UserAnswerTaskController.cs @@ -5,7 +5,7 @@ using QYZH.InteractiveMagazine.Models.Dto; using QYZH.InteractiveMagazine.Models.Dto.UserAnswerTaskService; using System.ComponentModel.DataAnnotations; -namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat; +namespace QYZH.InteractiveMagazine.WeChatApi.Controllers; /// /// 小程序首页控制器 diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatAuthController.cs b/QYZH.InteractiveMagazine.WeChatApi/Controllers/WeChatAuthController.cs similarity index 99% rename from QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatAuthController.cs rename to QYZH.InteractiveMagazine.WeChatApi/Controllers/WeChatAuthController.cs index 3e0bcae..3ce8d32 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatAuthController.cs +++ b/QYZH.InteractiveMagazine.WeChatApi/Controllers/WeChatAuthController.cs @@ -6,7 +6,7 @@ using QYZH.InteractiveMagazine.Models.Common; using QYZH.InteractiveMagazine.Models.Dto; using QYZH.InteractiveMagazine.Models.WeChat; -namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat; +namespace QYZH.InteractiveMagazine.WeChatApi.Controllers; /// /// 微信小程序认证控制器 diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatBaseController.cs b/QYZH.InteractiveMagazine.WeChatApi/Controllers/WeChatBaseController.cs similarity index 97% rename from QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatBaseController.cs rename to QYZH.InteractiveMagazine.WeChatApi/Controllers/WeChatBaseController.cs index ae97bc9..989677d 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatBaseController.cs +++ b/QYZH.InteractiveMagazine.WeChatApi/Controllers/WeChatBaseController.cs @@ -5,7 +5,7 @@ using QYZH.InteractiveMagazine.Models.Dto; using QYZH.InteractiveMagazine.Models.Enum; using System.Security.Claims; -namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat; +namespace QYZH.InteractiveMagazine.WeChatApi.Controllers; /// /// 小程序基础控制器 diff --git a/QYZH.InteractiveMagazine.WeChatApi/Program.cs b/QYZH.InteractiveMagazine.WeChatApi/Program.cs new file mode 100644 index 0000000..5144dfe --- /dev/null +++ b/QYZH.InteractiveMagazine.WeChatApi/Program.cs @@ -0,0 +1,189 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.OpenApi.Models; +using QYZH.InteractiveMagazine.Common.Extensions; +using QYZH.InteractiveMagazine.Common.Helpers; +using QYZH.InteractiveMagazine.Infrastructure.Autofacs; +using QYZH.InteractiveMagazine.Infrastructure.Context; +using QYZH.InteractiveMagazine.Infrastructure.Extensions; +using QYZH.InteractiveMagazine.Infrastructure.Middleware; +using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ; +using QYZH.InteractiveMagazine.Infrastructure.Redis; +using QYZH.InteractiveMagazine.Infrastructure.SDK; +using QYZH.InteractiveMagazine.Models.Enum; +using QYZH.InteractiveMagazine.Models.Settings; +using QYZH.InteractiveMagazine.Repository; +using QYZH.InteractiveMagazine.Repository.Core; +using Serilog; +using SqlSugar.IOC; +using Swashbuckle.AspNetCore.SwaggerGen; +using Swashbuckle.AspNetCore.SwaggerUI; +using System.Text.Json.Serialization; +using Yitter.IdGenerator; + +var builder = WebApplication.CreateBuilder(args); + +builder.Configuration.AddJsonFile("medal-rule-config.json", optional: false, reloadOnChange: true); + +YitIdHelper.SetIdGenerator(new IdGeneratorOptions { WorkerId = 1 }); + +builder.UseAutofac(); + +builder.InitSqlSugarDb(new IocConfig +{ + ConfigId = 0, + DbType = IocDbType.MySql, + ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection"), + IsAutoCloseConnection = true, +}); + +builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "DataProtection"))); + +builder.Services.AddCSRedisCacheExtension(builder.Configuration.GetSection("RedisSettings")); +builder.Services.AddRabbitMQ(builder.Configuration); + +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .CreateLogger(); + +builder.Host.UseSerilog(); + +builder.Services.AddControllers(options => +{ + options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true; + options.Filters.Add(); +}) +.AddJsonOptions(options => +{ + options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + options.JsonSerializerOptions.Converters.Add(new JsonConverterUtil.DateTimeConverter()); + options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; +}) +.ConfigureApiBehaviorOptions(opt => opt.SuppressModelStateInvalidFilter = true); + +builder.Services.AddEndpointsApiExplorer(); +builder.AddCorsRegister(); +builder.Services.AddHttpClient(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(typeof(BaseRepository<>)); +builder.Services.AddInfrastructureServices(builder.Configuration, builder.Environment); +builder.Services.AddSDKService(builder.Configuration); +builder.Services.Configure(builder.Configuration.GetSection("MedalRuleConfig")); + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +builder.Services.Configure(options => +{ + options.Level = System.IO.Compression.CompressionLevel.Optimal; +}); +builder.Services.Configure(options => +{ + options.Level = System.IO.Compression.CompressionLevel.Fastest; +}); +builder.Services.AddResponseCompression(options => +{ + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); + options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["image/svg+xml", "application/json", "text/plain"]); +}); + +builder.Services.AddSwaggerGen(option => +{ + var xmlFile = $"{AppDomain.CurrentDomain.FriendlyName}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + var modelXml = Path.Combine(AppContext.BaseDirectory, "QYZH.InteractiveMagazine.Models.xml"); + var version = ApiVersionEnum.Wechat; + + option.SwaggerDoc(version.ToString(), new OpenApiInfo + { + Title = AppDomain.CurrentDomain.FriendlyName, + Version = "互动期刊接口文档", + Description = $"{version.GetDescription()}接口,Last Modify Time:{new FileInfo(xmlPath).LastWriteTime:yyyy-MM-dd HH:mm:ss}" + }); + + option.OrderActionsBy(o => o.RelativePath); + + if (File.Exists(xmlPath)) + { + option.IncludeXmlComments(xmlPath, true); + } + + if (File.Exists(modelXml)) + { + option.IncludeXmlComments(modelXml, true); + } + + option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Description = "请输入 Token,格式为 Bearer Token", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + option.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + [] + } + }); + + option.DocInclusionPredicate((docName, apiDesc) => + { + if (!apiDesc.TryGetMethodInfo(out var methodInfo)) + { + return false; + } + + var groupName = methodInfo.DeclaringType? + .GetCustomAttributes(true) + .OfType() + .FirstOrDefault()? + .GroupName; + + return groupName == docName; + }); +}); + +var app = builder.Build(); + +app.UseSwagger(); +app.UseSwaggerUI(c => +{ + var version = ApiVersionEnum.Wechat; + c.SwaggerEndpoint($"/swagger/{version}/swagger.json", $"{version.GetDescription()}接口"); + c.DocExpansion(DocExpansion.None); +}); + +app.UseServiceContext(); +app.UseHttpsRedirection(); +app.UseCors("AllowAll"); +app.UseMiddleware(); +app.UseMiddleware(); +app.UseMiddleware(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); diff --git a/QYZH.InteractiveMagazine.WeChatApi/Properties/launchSettings.json b/QYZH.InteractiveMagazine.WeChatApi/Properties/launchSettings.json new file mode 100644 index 0000000..c171209 --- /dev/null +++ b/QYZH.InteractiveMagazine.WeChatApi/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:51131", + "sslPort": 44367 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5198", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7253;http://localhost:5198", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/QYZH.InteractiveMagazine.WeChatApi/QYZH.InteractiveMagazine.WeChatApi.csproj b/QYZH.InteractiveMagazine.WeChatApi/QYZH.InteractiveMagazine.WeChatApi.csproj new file mode 100644 index 0000000..000665b --- /dev/null +++ b/QYZH.InteractiveMagazine.WeChatApi/QYZH.InteractiveMagazine.WeChatApi.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + True + + + + + + + + + + + + + + + + + + diff --git a/QYZH.InteractiveMagazine.WeChatApi/appsettings.Development.json b/QYZH.InteractiveMagazine.WeChatApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/QYZH.InteractiveMagazine.WeChatApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/QYZH.InteractiveMagazine.WeChatApi/appsettings.json b/QYZH.InteractiveMagazine.WeChatApi/appsettings.json new file mode 100644 index 0000000..87c9381 --- /dev/null +++ b/QYZH.InteractiveMagazine.WeChatApi/appsettings.json @@ -0,0 +1,71 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "server=192.168.20.150;port=13306;database=InteractiveMagazine;user=user;password=n68792bu!y99r905;charset=utf8mb4;" + }, + "JwtSettings": { + "Issuer": "QYZH.InteractiveMagazine", + "Audience": "QYZH.InteractiveMagazine", + "SecretKey": "zG7pLqR9xVw2bN8fYtHk3mPc5sA1dF6eUjW4gXhC7vB", + "ExpiryMinutes": 120, + "JwtTokenExpiryDays": 30 + }, + "RedisSettings": { + "ConnectionString": "192.168.20.150:16379,defaultDatabase=5", + "Sentinels": [], + "ExpireSecondRange": [ 3600, 7200 ] + }, + "RabbitMq": { + "HostName": "192.168.20.150", + "Port": 5672, + "UserName": "smartschool", + "Password": "@ss%&*otz%d*pq2S", + "VirtualHost": "InteractiveMagazine", + "ClientProvidedName": "Custom connection name" + }, + "WeChatSettings": { + "AppId": "wx8c08da60bd207e64", + "AppSecret": "76fa314c34762c0347bd613e488c6872" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console" + }, + { + "Name": "File", + "Args": { + "path": "logs/log-.txt", + "rollingInterval": "Day" + } + } + ] + }, + "AllowedHosts": "*", + "AiChat": { + "ApiKey": "Ollama", + "BaseUrl": "http://172.16.10.130:11434/v1/", + "Model": "qwen2.5vl:7b", + "TimeoutSeconds": 300, + "MaxTokens": 2000, + "Temperature": 0.5 + }, + "AliyunOSSConfigs": { + "AccessKeyID": "LTAI5tEBXGewpHSLiSxyx6Bf", + "AccessKeySecret": "w29b8wkw6XQVL8GWXgp3ZesgYeDKvf", + "VodBucketName": "outin-5277bbb52bec11f08dbd00163e169e2b.oss-cn-beijing.aliyuncs.com", + "BucketName": "qyzh2025test", + "Region": "beijing", + "RoleArn": "acs:ram::1064745380176636:role/aliyunosstokengeneratorrole", + "DurationSeconds": 3600, //过期时间(秒) + "Endpoint": "oss-cn-beijing.aliyuncs.com", + "ProjectName": "InteractiveMagazine", + "Domain": "http://oss-test.qyzhjy.com/" + } +} diff --git a/QYZH.InteractiveMagazine.WeChatApi/medal-rule-config.json b/QYZH.InteractiveMagazine.WeChatApi/medal-rule-config.json new file mode 100644 index 0000000..bc290ad --- /dev/null +++ b/QYZH.InteractiveMagazine.WeChatApi/medal-rule-config.json @@ -0,0 +1,43 @@ +{ + "MedalRuleConfig": { + "Tables": [ + { + "TableName": "CheckInRecord", + "DisplayName": "签到记录", + "Fields": [ + { "FieldName": "ContinuousDays", "DisplayName": "连续天数" } + ] + }, + { + "TableName": "UserPet", + "DisplayName": "宠物", + "Fields": [ + { + "FieldName": "GrowthPoints", + "DisplayName": "成长值" + }, + { + "FieldName": "FeedingCount", + "DisplayName": "喂养次数" + }, + { + "FieldName": "Comprehension", + "DisplayName": "理解力" + }, + { + "FieldName": "Judgment", + "DisplayName": "判断力" + }, + { + "FieldName": "Expression", + "DisplayName": "表达力" + }, + { + "FieldName": "Persuasiveness", + "DisplayName": "说服力" + } + ] + } + ] + } +} diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/PermissionController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/PermissionController.cs new file mode 100644 index 0000000..05bd1a4 --- /dev/null +++ b/QYZH.InteractiveMagazine.WebApi/Controllers/PermissionController.cs @@ -0,0 +1,139 @@ +using Microsoft.AspNetCore.Mvc; +using QYZH.InteractiveMagazine.IService; +using QYZH.InteractiveMagazine.Models.Common; +using QYZH.InteractiveMagazine.Models.Dto; +using QYZH.InteractiveMagazine.Models.Enum; + +namespace QYZH.InteractiveMagazine.WebApi.Controllers; + +/// +/// 后台权限管理 +/// +[ApiController] +[Route("api/[controller]")] +[ApiExplorerSettings(GroupName = nameof(ApiVersionEnum.Platform))] +public class PermissionController(IAdminPermissionService adminPermissionService) : BaseController +{ + /// + /// 创建菜单 + /// + [HttpPost("menus")] + public async Task> CreateMenuAsync([FromBody] AdminMenuInput input) + { + var result = await adminPermissionService.CreateMenuAsync(input); + return Success(result); + } + + /// + /// 更新菜单 + /// + [HttpPut("menus/{id}")] + public async Task> UpdateMenuAsync(long id, [FromBody] AdminMenuInput input) + { + var result = await adminPermissionService.UpdateMenuAsync(id, input); + return Success(result); + } + + /// + /// 删除菜单 + /// + [HttpDelete("menus/{id}")] + public async Task> DeleteMenuAsync(long id) + { + await adminPermissionService.DeleteMenuAsync(id); + return Success(new object()); + } + + /// + /// 获取菜单树 + /// + [HttpPost("menus/tree")] + public async Task>> GetMenuTreeAsync([FromBody] AdminMenuQueryInput input) + { + var result = await adminPermissionService.GetMenuTreeAsync(input); + return Success(result); + } + + /// + /// 获取当前管理员菜单树 + /// + [HttpGet("menus/current")] + public async Task>> GetCurrentMenuTreeAsync() + { + var userId = GetCurrentUserId(); + BusinessException.ThrowIf(userId == null, "未获取到用户信息", ResultCode.DENY); + + var result = await adminPermissionService.GetAdminUserMenuTreeAsync(userId!.Value); + return Success(result); + } + + /// + /// 创建角色 + /// + [HttpPost("roles")] + public async Task> CreateRoleAsync([FromBody] AdminRoleInput input) + { + var result = await adminPermissionService.CreateRoleAsync(input); + return Success(result); + } + + /// + /// 更新角色 + /// + [HttpPut("roles/{id}")] + public async Task> UpdateRoleAsync(long id, [FromBody] AdminRoleInput input) + { + var result = await adminPermissionService.UpdateRoleAsync(id, input); + return Success(result); + } + + /// + /// 删除角色 + /// + [HttpDelete("roles/{id}")] + public async Task> DeleteRoleAsync(long id) + { + await adminPermissionService.DeleteRoleAsync(id); + return Success(new object()); + } + + /// + /// 获取角色详情 + /// + [HttpGet("roles/{id}")] + public async Task> GetRoleByIdAsync(long id) + { + var result = await adminPermissionService.GetRoleByIdAsync(id); + return Success(result); + } + + /// + /// 获取角色分页列表 + /// + [HttpPost("roles/list")] + public async Task>> GetRoleListAsync([FromBody] AdminRoleQueryInput input) + { + var result = await adminPermissionService.GetRoleListAsync(input); + return Success(result); + } + + /// + /// 分配角色菜单 + /// + [HttpPut("roles/{roleId}/menus")] + public async Task> AssignRoleMenusAsync(long roleId, [FromBody] AssignRoleMenusInput input) + { + await adminPermissionService.AssignRoleMenusAsync(roleId, input); + return Success(new object()); + } + + /// + /// 分配管理员角色 + /// + [HttpPut("users/{adminUserId}/roles")] + public async Task> AssignAdminUserRolesAsync(long adminUserId, [FromBody] AssignAdminUserRolesInput input) + { + await adminPermissionService.AssignAdminUserRolesAsync(adminUserId, input); + return Success(new object()); + } +} diff --git a/QYZH.InteractiveMagazine.WebApi/Program.cs b/QYZH.InteractiveMagazine.WebApi/Program.cs index 02d1683..392de5b 100644 --- a/QYZH.InteractiveMagazine.WebApi/Program.cs +++ b/QYZH.InteractiveMagazine.WebApi/Program.cs @@ -90,7 +90,7 @@ builder.Services.AddSwaggerGen(option => var modelXml = Path.Combine(AppContext.BaseDirectory, $"QYZH.InteractiveMagazine.Models.xml"); - Enum.GetValues().ToList().ForEach(version => + Enum.GetValues().Where(version => version != ApiVersionEnum.Wechat).ToList().ForEach(version => { // 配置文档信息 option.SwaggerDoc(version.ToString(), new OpenApiInfo @@ -201,7 +201,7 @@ var app = builder.Build(); app.UseSwaggerUI(c => { // 根据版本名称倒序 遍历展示 - Enum.GetValues().OrderBy(e => e).ToList().ForEach(version => + Enum.GetValues().Where(version => version != ApiVersionEnum.Wechat).OrderBy(e => e).ToList().ForEach(version => { c.SwaggerEndpoint($"/swagger/{version}/swagger.json", $"{version.GetDescription()}接口"); }); @@ -220,4 +220,3 @@ app.UseAuthorization(); app.MapControllers(); app.Run(); - diff --git a/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs b/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs index eac5b1b..0a5cd1d 100644 --- a/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs +++ b/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs @@ -47,6 +47,7 @@ public class JournalTaskReceiveConsumer( { PropertyNameCaseInsensitive = true }) ?? throw new InvalidOperationException("期刊任务消息内容为空"); + data.Normalize(); if (data.Questions == null || data.Questions.Length == 0) { @@ -1093,11 +1094,26 @@ public class QuestionData /// public long UserId { get; set; } + /// + /// 学生ID + /// + public long StudentId { get; set; } + /// /// 期刊ID /// public long JournalId { get; set; } + /// + /// 作业ID + /// + public long HomeworkId { get; set; } + + /// + /// 书籍ID + /// + public long BookId { get; set; } + /// /// 页ID /// @@ -1117,6 +1133,22 @@ public class QuestionData /// 创建时间 /// public DateTime CreatedTime { get; set; } + + /// + /// 兼容新版作答消息字段 + /// + public void Normalize() + { + if (UserId <= 0) + { + UserId = StudentId; + } + + if (JournalId <= 0) + { + JournalId = BookId > 0 ? BookId : HomeworkId; + } + } } /// diff --git a/QYZH.InteractiveMagazine.slnx b/QYZH.InteractiveMagazine.slnx index 6320146..3a2a8cf 100644 --- a/QYZH.InteractiveMagazine.slnx +++ b/QYZH.InteractiveMagazine.slnx @@ -9,5 +9,6 @@ +