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
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 @@
+