临时数据

This commit is contained in:
2026-07-06 14:27:56 +08:00
54 changed files with 3228 additions and 421 deletions

View File

@ -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);

5
Directory.Build.props Normal file
View File

@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<NoWarn>$(NoWarn);CS0105;CS0108;CS0168;CS0169;CS0618;CS1570;CS1572;CS1573;CS1591;CS8600;CS8601;CS8602;CS8603;CS8604;CS8618;CS8625;CS8629;CS8634;CS8714;CS9113</NoWarn>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,473 @@
using System.IO.Compression;
using System.Text;
namespace QYZH.InteractiveMagazine.Common.Helpers;
/// <summary>
/// 二维码帮助类
/// </summary>
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];
/// <summary>
/// 生成二维码PNG图片字节
/// </summary>
/// <param name="content">二维码内容</param>
/// <returns>PNG图片字节</returns>
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<bool>();
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<bool>();
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<byte> { 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<bool> bits, int value, int count)
{
for (var i = count - 1; i >= 0; i--)
{
bits.Add(((value >> i) & 1) != 0);
}
}
private static List<byte> BitsToBytes(List<bool> bits)
{
var result = new List<byte>();
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;
}
}

View File

@ -0,0 +1,37 @@
using System.Security.Cryptography;
namespace QYZH.InteractiveMagazine.Common.Helpers;
/// <summary>
/// 随机ID帮助类
/// </summary>
public static class RandomIdHelper
{
private const long DefaultMinValue = 1_000_000_000_000L;
private const long DefaultMaxValue = 9_000_000_000_000_000L;
/// <summary>
/// 生成不可预测的正数长整型ID
/// </summary>
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<byte> bytes = stackalloc byte[sizeof(ulong)];
while (true)
{
RandomNumberGenerator.Fill(bytes);
var value = BitConverter.ToUInt64(bytes);
if (value < limit)
{
return minValue + (long)(value % range);
}
}
}
}

View File

@ -0,0 +1,80 @@
using QYZH.InteractiveMagazine.Models.Dto;
using QYZH.InteractiveMagazine.Models.Entity;
namespace QYZH.InteractiveMagazine.IService;
/// <summary>
/// 后台权限管理服务接口
/// </summary>
public interface IAdminPermissionService : IBaseService<AdminRole>
{
/// <summary>
/// 创建菜单
/// </summary>
Task<AdminMenuOutput> CreateMenuAsync(AdminMenuInput input);
/// <summary>
/// 更新菜单
/// </summary>
Task<AdminMenuOutput> UpdateMenuAsync(long id, AdminMenuInput input);
/// <summary>
/// 删除菜单
/// </summary>
Task DeleteMenuAsync(long id);
/// <summary>
/// 获取菜单树
/// </summary>
Task<List<AdminMenuOutput>> GetMenuTreeAsync(AdminMenuQueryInput input);
/// <summary>
/// 创建角色
/// </summary>
Task<AdminRoleOutput> CreateRoleAsync(AdminRoleInput input);
/// <summary>
/// 更新角色
/// </summary>
Task<AdminRoleOutput> UpdateRoleAsync(long id, AdminRoleInput input);
/// <summary>
/// 删除角色
/// </summary>
Task DeleteRoleAsync(long id);
/// <summary>
/// 获取角色详情
/// </summary>
Task<AdminRoleOutput> GetRoleByIdAsync(long id);
/// <summary>
/// 获取角色分页列表
/// </summary>
Task<PageListModel<AdminRoleOutput>> GetRoleListAsync(AdminRoleQueryInput input);
/// <summary>
/// 分配角色菜单
/// </summary>
Task AssignRoleMenusAsync(long roleId, AssignRoleMenusInput input);
/// <summary>
/// 分配管理员角色
/// </summary>
Task AssignAdminUserRolesAsync(long adminUserId, AssignAdminUserRolesInput input);
/// <summary>
/// 获取管理员菜单树
/// </summary>
Task<List<AdminMenuOutput>> GetAdminUserMenuTreeAsync(long adminUserId);
/// <summary>
/// 获取管理员角色
/// </summary>
Task<List<AdminRoleSimpleOutput>> GetAdminUserRolesAsync(long adminUserId);
/// <summary>
/// 获取管理员权限编码
/// </summary>
Task<List<string>> GetAdminUserPermissionCodesAsync(long adminUserId);
}

View File

@ -32,12 +32,12 @@ public interface IUserJournalService : IBaseService<UserJournal>
Task UnbindJournalAsync(long userId, long id);
/// <summary>
/// 生成期刊二维码记录
/// 批量提交期刊二维码生成任务
/// </summary>
/// <param name="input">生成输入</param>
/// <param name="operatorId">操作人Id</param>
/// <returns>二维码记录</returns>
Task<UserJournalQrCodeOutput> CreateQrCodeAsync(CreateUserJournalQrCodeInput input, long operatorId);
/// <param name="operatorName">操作人名称</param>
/// <returns>提交结果</returns>
Task<CreateUserJournalQrCodeOutput> CreateQrCodesAsync(CreateUserJournalQrCodeInput input, string operatorName);
/// <summary>
/// 分页查询期刊二维码记录
@ -53,11 +53,18 @@ public interface IUserJournalService : IBaseService<UserJournal>
/// <returns>二维码记录</returns>
Task<UserJournalQrCodeOutput> GetQrCodeDetailAsync(long id);
/// <summary>
/// 根据期刊Id获取未绑定用户的二维码列表
/// </summary>
/// <param name="journalId">期刊Id</param>
/// <returns>未绑定二维码列表</returns>
Task<List<UserJournalQrCodeOutput>> GetUnboundQrCodesByJournalIdAsync(long journalId);
/// <summary>
/// 删除未绑定的期刊二维码记录
/// </summary>
/// <param name="input">删除输入</param>
/// <param name="operatorId">操作人Id</param>
/// <param name="operatorName">操作人名称</param>
/// <returns>是否成功</returns>
Task<bool> DeleteQrCodeAsync(DeleteUserJournalQrCodeInput input, long operatorId);
Task<bool> DeleteQrCodeAsync(DeleteUserJournalQrCodeInput input, string operatorName);
}

View File

@ -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));
}
}

View File

@ -21,6 +21,9 @@ namespace QYZH.InteractiveMagazine.Infrastructure.Extensions;
/// </summary>
public static class DependencyInjectionExtensions
{
private const string NoAuthScheme = "NoAuth";
private const string DevelopmentAuthScheme = "DevelopmentSmartAuth";
/// <summary>
/// Registers infrastructure services.
/// </summary>
@ -34,19 +37,37 @@ public static class DependencyInjectionExtensions
private static void AddJwtAuthentication(IServiceCollection services, IConfiguration configuration, IWebHostEnvironment? environment = null)
{
var jwtSettings = configuration.GetSection("JwtSettings").Get<JwtSettings>()!;
services.AddSingleton(jwtSettings);
if (environment?.IsDevelopment() == true)
{
services.AddAuthentication("NoAuth")
.AddScheme<AuthenticationSchemeOptions, NoAuthHandler>("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<AuthenticationSchemeOptions, NoAuthHandler>(NoAuthScheme, options => { });
return;
}
var jwtSettings = configuration.GetSection("JwtSettings").Get<JwtSettings>()!;
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
{
@ -78,26 +99,28 @@ public static class DependencyInjectionExtensions
return;
}
var adminToken = await RedisHelper.GetAsync(JwtHelper.BuildAdminTokenKey(userId));
var wxUserId = context.Principal?.FindFirst(JwtHelper.WxUserIdClaimType)?.Value;
if (string.IsNullOrEmpty(wxUserId) && !string.IsNullOrEmpty(adminToken))
if (string.IsNullOrEmpty(wxUserId))
{
var adminToken = await RedisHelper.GetAsync(JwtHelper.BuildAdminTokenKey(userId));
if (adminToken == currentToken)
{
return;
}
if (!string.IsNullOrEmpty(wxUserId))
{
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)

View File

@ -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<IChannel, BasicDeliverEventArgs, Task> callback, CancellationToken cancellationToken = default)
{
var channel = await _connection.CreateChannel();
//await channel.BasicQosAsync(0, 10, false); // 一次最多接收10条未确认的消息
var prefetchCount = _configuration.GetValue<ushort>("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);

View File

@ -0,0 +1,270 @@
namespace QYZH.InteractiveMagazine.Models.Dto;
/// <summary>
/// 后台菜单输入
/// </summary>
public class AdminMenuInput
{
/// <summary>
/// 父级菜单ID根节点为0
/// </summary>
public long ParentId { get; set; }
/// <summary>
/// 菜单名称
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 权限编码
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 前端路由路径
/// </summary>
public string? Path { get; set; }
/// <summary>
/// 前端组件路径
/// </summary>
public string? Component { get; set; }
/// <summary>
/// 菜单图标
/// </summary>
public string? Icon { get; set; }
/// <summary>
/// 排序值
/// </summary>
public int Sort { get; set; }
/// <summary>
/// 是否显示在菜单
/// </summary>
public bool IsVisible { get; set; } = true;
/// <summary>
/// 状态
/// </summary>
public int Status { get; set; } = 1;
}
/// <summary>
/// 后台菜单输出
/// </summary>
public class AdminMenuOutput
{
/// <summary>
/// 菜单ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// 父级菜单ID
/// </summary>
public long ParentId { get; set; }
/// <summary>
/// 菜单名称
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 权限编码
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 前端路由路径
/// </summary>
public string? Path { get; set; }
/// <summary>
/// 前端组件路径
/// </summary>
public string? Component { get; set; }
/// <summary>
/// 菜单图标
/// </summary>
public string? Icon { get; set; }
/// <summary>
/// 排序值
/// </summary>
public int Sort { get; set; }
/// <summary>
/// 是否显示在菜单
/// </summary>
public bool IsVisible { get; set; }
/// <summary>
/// 状态
/// </summary>
public int Status { get; set; }
/// <summary>
/// 子菜单
/// </summary>
public List<AdminMenuOutput> Children { get; set; } = [];
}
/// <summary>
/// 后台菜单查询输入
/// </summary>
public class AdminMenuQueryInput
{
/// <summary>
/// 菜单名称
/// </summary>
public string? Name { get; set; }
/// <summary>
/// 权限编码
/// </summary>
public string? Code { get; set; }
/// <summary>
/// 状态
/// </summary>
public int? Status { get; set; }
}
/// <summary>
/// 后台角色输入
/// </summary>
public class AdminRoleInput
{
/// <summary>
/// 角色名称
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 角色编码
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 备注
/// </summary>
public string? Remark { get; set; }
/// <summary>
/// 状态
/// </summary>
public int Status { get; set; } = 1;
/// <summary>
/// 菜单ID集合
/// </summary>
public List<long> MenuIds { get; set; } = [];
}
/// <summary>
/// 后台角色输出
/// </summary>
public class AdminRoleOutput
{
/// <summary>
/// 角色ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// 角色名称
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 角色编码
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 备注
/// </summary>
public string? Remark { get; set; }
/// <summary>
/// 状态
/// </summary>
public int Status { get; set; }
/// <summary>
/// 菜单ID集合
/// </summary>
public List<long> MenuIds { get; set; } = [];
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreatedAt { get; set; }
}
/// <summary>
/// 后台角色简要输出
/// </summary>
public class AdminRoleSimpleOutput
{
/// <summary>
/// 角色ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// 角色名称
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 角色编码
/// </summary>
public string Code { get; set; } = string.Empty;
}
/// <summary>
/// 后台角色查询输入
/// </summary>
public class AdminRoleQueryInput : PageQueryModel
{
/// <summary>
/// 角色名称
/// </summary>
public string? Name { get; set; }
/// <summary>
/// 角色编码
/// </summary>
public string? Code { get; set; }
/// <summary>
/// 状态
/// </summary>
public int? Status { get; set; }
}
/// <summary>
/// 分配角色菜单输入
/// </summary>
public class AssignRoleMenusInput
{
/// <summary>
/// 菜单ID集合
/// </summary>
public List<long> MenuIds { get; set; } = [];
}
/// <summary>
/// 分配用户角色输入
/// </summary>
public class AssignAdminUserRolesInput
{
/// <summary>
/// 角色ID集合
/// </summary>
public List<long> RoleIds { get; set; } = [];
}

View File

@ -27,6 +27,11 @@ public class AdminUserInput
/// 状态: Active, Inactive
/// </summary>
public int Status { get; set; } = 1;
/// <summary>
/// 角色ID集合更新时为null表示不调整角色
/// </summary>
public List<long>? RoleIds { get; set; }
}
/// <summary>
@ -73,6 +78,16 @@ public class AdminUserOutput
/// 更新时间
/// </summary>
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// 角色ID集合
/// </summary>
public List<long> RoleIds { get; set; } = [];
/// <summary>
/// 角色集合
/// </summary>
public List<AdminRoleSimpleOutput> Roles { get; set; } = [];
}
/// <summary>

View File

@ -26,6 +26,26 @@ public class AdminLoginOutput
public string UserName { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
/// <summary>
/// 角色ID集合
/// </summary>
public List<long> RoleIds { get; set; } = [];
/// <summary>
/// 角色集合
/// </summary>
public List<AdminRoleSimpleOutput> Roles { get; set; } = [];
/// <summary>
/// 菜单树
/// </summary>
public List<AdminMenuOutput> Menus { get; set; } = [];
/// <summary>
/// 权限编码集合
/// </summary>
public List<string> 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; }
/// <summary>
/// 角色ID集合
/// </summary>
public List<long> RoleIds { get; set; } = [];
/// <summary>
/// 角色集合
/// </summary>
public List<AdminRoleSimpleOutput> Roles { get; set; } = [];
/// <summary>
/// 菜单树
/// </summary>
public List<AdminMenuOutput> Menus { get; set; } = [];
/// <summary>
/// 权限编码集合
/// </summary>
public List<string> PermissionCodes { get; set; } = [];
}

View File

@ -1,4 +1,5 @@
using QYZH.InteractiveMagazine.Models.Enum;
using System.Text.Json.Serialization;
namespace QYZH.InteractiveMagazine.Models.Dto;
@ -10,17 +11,15 @@ public class BindJournalInput
/// <summary>
/// 期刊模板Id扫码解析的期刊定义Id
/// </summary>
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public long JournalId { get; set; }
/// <summary>
/// 实例化期刊Id扫码解析的具体期刊实例Id可选
/// </summary>
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public long Id { get; set; }
/// <summary>
/// 关联类型: Read(已读), Favorite(收藏), Subscribe(订阅),默认 Subscribe
/// </summary>
public string Type { get; set; } = UserJournalTypeEnum.Subscribe.ToString();
}
/// <summary>
@ -122,14 +121,67 @@ public class CreateUserJournalQrCodeInput
public long JournalId { get; set; }
/// <summary>
/// 关联类型: Read, Favorite, Subscribe默认 Subscribe
/// 生成数量
/// </summary>
public string Type { get; set; } = UserJournalTypeEnum.Subscribe.ToString();
public int Count { get; set; } = 1;
}
/// <summary>
/// 生成期刊二维码提交结果
/// </summary>
public class CreateUserJournalQrCodeOutput
{
/// <summary>
/// 期刊Id
/// </summary>
public long JournalId { get; set; }
/// <summary>
/// 请求生成数量
/// </summary>
public int RequestedCount { get; set; }
/// <summary>
/// 已提交生成数量
/// </summary>
public int AcceptedCount { get; set; }
/// <summary>
/// 二维码记录Id列表
/// </summary>
[JsonNumberHandling(JsonNumberHandling.WriteAsString)]
public List<long> RecordIds { get; set; } = [];
/// <summary>
/// 是否后台生成
/// </summary>
public bool IsAsync { get; set; }
/// <summary>
/// 提示信息
/// </summary>
public string Message { get; set; } = string.Empty;
}
/// <summary>
/// 期刊二维码查询输入DTO
/// </summary>
public class GenerateUserJournalQrCodeMessage
{
/// <summary>
/// 二维码记录Id列表
/// </summary>
public List<long> RecordIds { get; set; } = [];
/// <summary>
/// 操作人名称
/// </summary>
public string OperatorName { get; set; } = string.Empty;
}
/// <summary>
/// 鏈熷垔浜岀淮鐮佹煡璇㈣緭鍏TO
/// </summary>
public class UserJournalQrCodeQueryInput : PageQueryModel
{
/// <summary>
@ -161,6 +213,7 @@ public class UserJournalQrCodeOutput
/// <summary>
/// 二维码记录Id
/// </summary>
[JsonNumberHandling(JsonNumberHandling.WriteAsString)]
public long Id { get; set; }
/// <summary>
@ -201,7 +254,7 @@ public class UserJournalQrCodeOutput
/// <summary>
/// 二维码内容
/// </summary>
public string QrCodeContent { get; set; } = string.Empty;
public string QrCodeUrl { get; set; } = string.Empty;
/// <summary>
/// 创建时间
@ -222,5 +275,6 @@ public class DeleteUserJournalQrCodeInput
/// <summary>
/// 二维码记录Id列表
/// </summary>
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public List<long> Ids { get; set; } = [];
}

View File

@ -26,6 +26,11 @@ public class UsersOutput
/// </summary>
public string WxUserId { get; set; } = string.Empty;
/// <summary>
/// 微信用户名
/// </summary>
public string? WxUserName { get; set; }
/// <summary>
/// 昵称
/// </summary>

View File

@ -0,0 +1,66 @@
using SqlSugar;
namespace QYZH.InteractiveMagazine.Models.Entity;
/// <summary>
/// 后台菜单表
/// </summary>
[SugarTable("AdminMenu")]
public partial class AdminMenu : SqlSugarBaseEntity
{
/// <summary>
/// Desc:父级菜单ID根节点为0
/// Default:0
/// Nullable:False
/// </summary>
public long ParentId { get; set; }
/// <summary>
/// Desc:菜单名称
/// Default:
/// Nullable:False
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Desc:权限编码
/// Default:
/// Nullable:False
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// Desc:前端路由路径
/// Default:
/// Nullable:True
/// </summary>
public string? Path { get; set; }
/// <summary>
/// Desc:前端组件路径
/// Default:
/// Nullable:True
/// </summary>
public string? Component { get; set; }
/// <summary>
/// Desc:菜单图标
/// Default:
/// Nullable:True
/// </summary>
public string? Icon { get; set; }
/// <summary>
/// Desc:排序值
/// Default:0
/// Nullable:False
/// </summary>
public int Sort { get; set; }
/// <summary>
/// Desc:是否显示在菜单
/// Default:b'1'
/// Nullable:False
/// </summary>
public bool IsVisible { get; set; } = true;
}

View File

@ -0,0 +1,31 @@
using SqlSugar;
namespace QYZH.InteractiveMagazine.Models.Entity;
/// <summary>
/// 后台角色表
/// </summary>
[SugarTable("AdminRole")]
public partial class AdminRole : SqlSugarBaseEntity
{
/// <summary>
/// Desc:角色名称
/// Default:
/// Nullable:False
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Desc:角色编码
/// Default:
/// Nullable:False
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// Desc:备注
/// Default:
/// Nullable:True
/// </summary>
public string? Remark { get; set; }
}

View File

@ -0,0 +1,24 @@
using SqlSugar;
namespace QYZH.InteractiveMagazine.Models.Entity;
/// <summary>
/// 后台角色菜单关系表
/// </summary>
[SugarTable("AdminRoleMenu")]
public partial class AdminRoleMenu : SqlSugarBaseEntity
{
/// <summary>
/// Desc:角色ID
/// Default:
/// Nullable:False
/// </summary>
public long RoleId { get; set; }
/// <summary>
/// Desc:菜单ID
/// Default:
/// Nullable:False
/// </summary>
public long MenuId { get; set; }
}

View File

@ -0,0 +1,24 @@
using SqlSugar;
namespace QYZH.InteractiveMagazine.Models.Entity;
/// <summary>
/// 后台管理员角色关系表
/// </summary>
[SugarTable("AdminUserRole")]
public partial class AdminUserRole : SqlSugarBaseEntity
{
/// <summary>
/// Desc:管理员ID
/// Default:
/// Nullable:False
/// </summary>
public long AdminUserId { get; set; }
/// <summary>
/// Desc:角色ID
/// Default:
/// Nullable:False
/// </summary>
public long RoleId { get; set; }
}

View File

@ -31,12 +31,18 @@ namespace QYZH.InteractiveMagazine.Models.Entity
public long JournalId { get; set; }
/// <summary>
/// Desc:关联类型: Read(已读), Favorite(收藏), Subscribe(订阅)
/// Default:Read
/// Desc:关联类型: 默认0
/// Nullable:False
/// </summary>
[SugarColumn(ColumnName = "Type")]
public UserJournalTypeEnum Type { get; set; }
public int Type { get; set; } = 0;
/// <summary>
/// Desc:二维码地址
/// Default:
/// Nullable:True
/// </summary>
[SugarColumn(ColumnName = "QrCodeUrl")]
public string? QrCodeUrl { get; set; }
}
}

View File

@ -17,5 +17,17 @@ public enum UserJournalStatusEnum
/// 正常
/// </summary>
[Description("正常")]
Active = 1
Active = 1,
/// <summary>
/// 生成中
/// </summary>
[Description("生成中")]
Generating = 2,
/// <summary>
/// 生成失败
/// </summary>
[Description("生成失败")]
Failed = 3
}

View File

@ -1,25 +0,0 @@
using System.ComponentModel;
namespace QYZH.InteractiveMagazine.Models.Enum;
/// <summary>
/// 用户期刊关联类型枚举
/// </summary>
public enum UserJournalTypeEnum
{
/// <summary>
/// 已读
/// </summary>
[Description("已读")]
Read = 1,
/// <summary>
/// 收藏
/// </summary>
[Description("收藏")]
Favorite = 2,
/// <summary>
/// 订阅
/// </summary>
[Description("订阅")]
Subscribe = 3
}

View File

@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Http;
using QYZH.InteractiveMagazine.Infrastructure.Context;
using QYZH.InteractiveMagazine.Models.Entity;
using Microsoft.AspNetCore.Http;
using QYZH.InteractiveMagazine.Infrastructure.Context;
using SqlSugar;
using System.Linq.Expressions;
using System.Reflection;
@ -120,20 +122,14 @@ namespace QYZH.InteractiveMagazine.Repository.Core
db.Aop.DataExecuting = (oldValue, entityInfo) =>
{
var entityValue = entityInfo.EntityColumnInfo.PropertyInfo.GetValue(entityInfo.EntityValue)?.ToString();
//var currnetUserName = "";
//var currentNameIdentifier = ServiceContext.GetRequiredService<IHttpContextAccessor>().HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "";
//var currentUserId = string.IsNullOrWhiteSpace(currentNameIdentifier) ? 0 : long.Parse(currentNameIdentifier);
var currentClaimName = ServiceContext.GetRequiredService<IHttpContextAccessor>().HttpContext?.User?.FindFirst(ClaimTypes.Name)?.Value;
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(currentClaimName);//修改创建人字段
entityInfo.SetValue(currnetUserName);//修改创建人字段
else if (entityInfo.PropertyName == "IsDeleted" && string.IsNullOrWhiteSpace(entityValue))
entityInfo.SetValue("0");//修改CreateTime字段
}
@ -144,7 +140,7 @@ 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(currentClaimName);//修改更新人字段
entityInfo.SetValue(currnetUserName);//修改更新人字段
}
};
return db;
@ -152,6 +148,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;
}
/// <summary>
/// 把一个字符串转成驼峰规则的字符串
/// </summary>

View File

@ -12,7 +12,11 @@ using QYZH.InteractiveMagazine.Repository;
namespace QYZH.InteractiveMagazine.Service;
public class AdminAuthService(BaseRepository<AdminUser> adminUserRepository, IConfiguration configuration, ILogger<AdminAuthService> logger) : BaseRepository<AdminUser>, IAdminAuthService
public class AdminAuthService(
BaseRepository<AdminUser> adminUserRepository,
IAdminPermissionService adminPermissionService,
IConfiguration configuration,
ILogger<AdminAuthService> logger) : BaseRepository<AdminUser>, IAdminAuthService
{
private const string TokenKeyPrefix = "InteractiveMagazine:AdminAuth:Token";
@ -60,12 +64,20 @@ public class AdminAuthService(BaseRepository<AdminUser> 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<AdminUser> 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
};
}

View File

@ -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;
/// <summary>
/// 后台权限管理服务实现
/// </summary>
public class AdminPermissionService(
BaseRepository<AdminRole> adminRoleRepository,
BaseRepository<AdminMenu> adminMenuRepository,
BaseRepository<AdminRoleMenu> adminRoleMenuRepository,
BaseRepository<AdminUserRole> adminUserRoleRepository,
BaseRepository<AdminUser> adminUserRepository,
ILogger<AdminPermissionService> logger) : BaseRepository<AdminRole>, IAdminPermissionService
{
/// <summary>
/// 创建菜单
/// </summary>
public async Task<AdminMenuOutput> 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);
}
/// <summary>
/// 更新菜单
/// </summary>
public async Task<AdminMenuOutput> 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);
}
/// <summary>
/// 删除菜单
/// </summary>
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);
}
/// <summary>
/// 获取菜单树
/// </summary>
public async Task<List<AdminMenuOutput>> 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);
}
/// <summary>
/// 创建角色
/// </summary>
public async Task<AdminRoleOutput> 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);
}
/// <summary>
/// 更新角色
/// </summary>
public async Task<AdminRoleOutput> 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);
}
/// <summary>
/// 删除角色
/// </summary>
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);
});
}
/// <summary>
/// 获取角色详情
/// </summary>
public async Task<AdminRoleOutput> 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);
}
/// <summary>
/// 获取角色分页列表
/// </summary>
public async Task<PageListModel<AdminRoleOutput>> GetRoleListAsync(AdminRoleQueryInput input)
{
RefAsync<int> 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<AdminRoleOutput>(outputs, input.PageIndex, input.PageSize, totalNumber);
}
/// <summary>
/// 分配角色菜单
/// </summary>
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));
}
/// <summary>
/// 分配管理员角色
/// </summary>
public async Task AssignAdminUserRolesAsync(long adminUserId, AssignAdminUserRolesInput input)
{
await ReplaceAdminUserRolesAsync(adminUserId, input.RoleIds);
}
/// <summary>
/// 获取管理员菜单树
/// </summary>
public async Task<List<AdminMenuOutput>> GetAdminUserMenuTreeAsync(long adminUserId)
{
var menus = await GetAdminUserMenusAsync(adminUserId, true);
return BuildMenuTree(menus);
}
/// <summary>
/// 获取管理员角色
/// </summary>
public async Task<List<AdminRoleSimpleOutput>> GetAdminUserRolesAsync(long adminUserId)
{
var adminUser = await adminUserRepository.GetByIdAsync(adminUserId);
BusinessException.ThrowIf(adminUser == null, "管理员不存在", ResultCode.NOT_FOUND);
return await adminRoleRepository.Queryable()
.InnerJoin<AdminUserRole>((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();
}
/// <summary>
/// 获取管理员权限编码
/// </summary>
public async Task<List<string>> 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<long> 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<long> 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<long> 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<long> 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<List<AdminMenu>> 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<AdminRoleMenu>((menu, roleMenu) => menu.Id == roleMenu.MenuId)
.InnerJoin<AdminRole>((menu, roleMenu, role) => roleMenu.RoleId == role.Id)
.InnerJoin<AdminUserRole>((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<AdminMenuOutput> BuildMenuTree(List<AdminMenu> 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<long> menuIds)
{
return new AdminRoleOutput
{
Id = role.Id,
Name = role.Name,
Code = role.Code,
Remark = role.Remark,
Status = role.Status,
MenuIds = menuIds,
CreatedAt = role.CreatedAt
};
}
}

View File

@ -15,7 +15,10 @@ namespace QYZH.InteractiveMagazine.Service;
/// <summary>
/// 管理员用户服务实现
/// </summary>
public class AdminUserService(BaseRepository<AdminUser> adminUserRepository, ILogger<AdminUserService> logger) : BaseRepository<AdminUser>, IAdminUserService
public class AdminUserService(
BaseRepository<AdminUser> adminUserRepository,
IAdminPermissionService adminPermissionService,
ILogger<AdminUserService> logger) : BaseRepository<AdminUser>, IAdminUserService
{
/// <summary>
@ -63,9 +66,14 @@ public class AdminUserService(BaseRepository<AdminUser> 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);
}
/// <summary>
@ -114,9 +122,14 @@ public class AdminUserService(BaseRepository<AdminUser> 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);
}
/// <summary>
@ -156,17 +169,7 @@ public class AdminUserService(BaseRepository<AdminUser> 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);
}
/// <summary>
@ -201,6 +204,12 @@ public class AdminUserService(BaseRepository<AdminUser> 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<AdminUserOutput>(pageResult, input.PageIndex, input.PageSize, totalNumber);
}
@ -231,4 +240,22 @@ public class AdminUserService(BaseRepository<AdminUser> adminUserRepository, ILo
logger.LogInformation("管理员状态更新成功ID: {Id}", id);
}
private async Task<AdminUserOutput> 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
};
}
}

View File

@ -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,20 +22,23 @@ public class UserJournalService(
BaseRepository<Journal> journalRepository,
ILogger<UserJournalService> logger,
IRabbitMQService rabbitMqService,
OssService ossService,
IPetService petService)
: BaseRepository<UserJournal>, IUserJournalService
{
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;
private const int MaxRandomIdGenerateRetryCount = 5;
/// <summary>
/// 用户绑定期刊(扫码绑定)
/// </summary>
public async Task<BindJournalOutput> 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)
@ -41,7 +46,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);
@ -63,24 +73,17 @@ public class UserJournalService(
throw new BusinessException("该期刊暂未发布,无法绑定", ResultCode.UNPROCESSABLE_ENTITY);
}
// 防重复绑定:同一用户 + 期刊 + 实例 + 类型
if (!Enum.TryParse<UserJournalTypeEnum>(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);
throw new BusinessException("二维码不存在或已失效", ResultCode.NOT_FOUND);
}
if (userJournal.Status != (int)UserJournalStatusEnum.Active)
if (userJournal.Status != activeStatus)
{
throw new BusinessException("二维码已失效", ResultCode.UNPROCESSABLE_ENTITY);
}
@ -91,16 +94,33 @@ public class UserJournalService(
throw new BusinessException("该期刊已被绑定", ResultCode.BAD_REQUEST);
}
var isFirstBind = !userJournalRepository.Context.Queryable<UserJournal>()
.Any(uj => uj.UserId == userId);
var isFirstBind = false;
await userJournalRepository.UseTranAsync(async () =>
{
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.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))
.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)
@ -109,11 +129,12 @@ public class UserJournalService(
throw new BusinessException("绑定期刊失败,请稍后重试", ResultCode.GLOBAL_ERROR);
}
});
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;
userJournal.UpdatedAt = boundAt;
await SendBindJournalMessageAsync(user, journal);
@ -145,16 +166,16 @@ public class UserJournalService(
/// <summary>
/// 生成期刊二维码记录
/// </summary>
public async Task<UserJournalQrCodeOutput> CreateQrCodeAsync(CreateUserJournalQrCodeInput input, long operatorId)
public async Task<CreateUserJournalQrCodeOutput> CreateQrCodesAsync(CreateUserJournalQrCodeInput input, string operatorName)
{
if (input.JournalId <= 0)
{
throw new BusinessException("期刊Id不能为空", ResultCode.BAD_REQUEST);
}
if (!Enum.TryParse<UserJournalTypeEnum>(input.Type, true, out var type))
if (input.Count <= 0 || input.Count > MaxBatchQrCodeCount)
{
throw new BusinessException("关联类型不正确", ResultCode.BAD_REQUEST);
throw new BusinessException($"生成数量必须在1-{MaxBatchQrCodeCount}之间", ResultCode.BAD_REQUEST);
}
var journal = await journalRepository.GetByIdAsync(input.JournalId);
@ -168,31 +189,67 @@ public class UserJournalService(
throw new BusinessException("该期刊暂未发布,无法生成二维码", ResultCode.UNPROCESSABLE_ENTITY);
}
var record = new UserJournal
var now = DateTime.Now;
var randomIds = await GenerateUniqueQrCodeIdsAsync(input.Count);
var records = randomIds
.Select(id => new UserJournal
{
Id = id,
UserId = null,
JournalId = input.JournalId,
Type = type,
Status = (int)UserJournalStatusEnum.Active,
Type = 0,
Status = (int)UserJournalStatusEnum.Generating,
IsDeleted = false,
CreatedBy = operatorId.ToString(),
CreatedAt = DateTime.Now,
UpdatedBy = operatorId.ToString(),
UpdatedAt = DateTime.Now
};
CreatedBy = operatorName,
CreatedAt = now,
UpdatedBy = operatorName,
UpdatedAt = now
})
.ToList();
var result = await userJournalRepository.InsertAsync(record);
if (!result)
var insertCount = await userJournalRepository.Context.Insertable(records).ExecuteCommandAsync();
if (insertCount <= 0)
{
throw new BusinessException("生成二维码失败,请稍后重试", ResultCode.GLOBAL_ERROR);
throw new BusinessException("提交二维码生成任务失败,请稍后重试", ResultCode.GLOBAL_ERROR);
}
return MapQrCodeOutput(record, journal, null);
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 = "二维码生成任务已提交,请稍后查询未绑定二维码列表"
};
}
/// <summary>
/// 分页查询期刊二维码记录
/// </summary>
public async Task<PageListModel<UserJournalQrCodeOutput>> GetQrCodePageListAsync(UserJournalQrCodeQueryInput input)
{
if (input.PageIndex <= 0)
@ -239,7 +296,7 @@ public class UserJournalService(
/// <summary>
/// 删除未绑定的期刊二维码记录
/// </summary>
public async Task<bool> DeleteQrCodeAsync(DeleteUserJournalQrCodeInput input, long operatorId)
public async Task<bool> DeleteQrCodeAsync(DeleteUserJournalQrCodeInput input, string operatorName)
{
if (input.Ids == null || input.Ids.Count == 0)
{
@ -264,7 +321,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();
@ -272,6 +329,24 @@ public class UserJournalService(
return updateCount == ids.Count;
}
public async Task<List<UserJournalQrCodeOutput>> 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<List<UserJournalQrCodeOutput>> BuildQrCodeOutputsAsync(List<UserJournal> records)
{
if (records.Count == 0)
@ -322,7 +397,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
};
@ -330,7 +405,52 @@ 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<long> 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<List<long>> GenerateUniqueQrCodeIdsAsync(int count)
{
var ids = new HashSet<long>();
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)
@ -384,7 +504,6 @@ public class UserJournalService(
RefAsync<int> 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
{

View File

@ -28,10 +28,27 @@ public class UsersService(
/// </summary>
public async Task<BaseResponse<PageListModel<UsersOutput>>> GetListAsync(UsersQueryInput input)
{
var page = Queryable()
.WhereIF(!string.IsNullOrEmpty(input.WxUserId), u => u.WxUserId.ToString() == input.WxUserId)
.OrderBy(u => u.Id, OrderByType.Desc)
.ToPage<Users, UsersOutput>(input);
RefAsync<int> totalNumber = 0;
var list = await Queryable()
.LeftJoin<WxUser>((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<UsersOutput>(list, input.PageIndex, input.PageSize, totalNumber);
return BaseResponse<PageListModel<UsersOutput>>.Success(page);
}
@ -41,7 +58,23 @@ public class UsersService(
/// </summary>
public async Task<BaseResponse<UserDetailOutput>> GetDetailAsync(long id)
{
var user = await GetByIdAsync<UsersOutput>(u => u.Id == id);
var user = await Queryable()
.LeftJoin<WxUser>((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<UserDetailOutput>.Fail("用户不存在");

View File

@ -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;
/// <summary>
/// 小程序背包控制器

View File

@ -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;
/// <summary>
/// 签到控制器

View File

@ -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;
/// <summary>
/// 小程序社区控制器

View File

@ -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;
/// <summary>
/// 小程序期刊管理控制器

View File

@ -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;
/// <summary>
/// 小程序商城控制器

View File

@ -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;
/// <summary>
/// 小程序勋章控制器

View File

@ -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;
/// <summary>
/// 小程序宠物管理控制器

View File

@ -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;
/// <summary>
/// 小程序首页控制器

View File

@ -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;
/// <summary>
/// 微信小程序认证控制器

View File

@ -6,7 +6,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;
/// <summary>
/// 小程序基础控制器

View File

@ -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<ModelValidActionFilterAttribute>();
})
.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<MedalRuleConfigSettings>(builder.Configuration.GetSection("MedalRuleConfig"));
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
options.Level = System.IO.Compression.CompressionLevel.Optimal;
});
builder.Services.Configure<GzipCompressionProviderOptions>(options =>
{
options.Level = System.IO.Compression.CompressionLevel.Fastest;
});
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
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<ApiExplorerSettingsAttribute>()
.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<GlobalExceptionMiddleware>();
app.UseMiddleware<OperationLogMiddleware>();
app.UseMiddleware<JwtAutoRefreshMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@ -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"
}
}
}
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Autofac" Version="9.1.0" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="11.0.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\QYZH.InteractiveMagazine.IService\QYZH.InteractiveMagazine.IService.csproj" />
<ProjectReference Include="..\QYZH.InteractiveMagazine.Service\QYZH.InteractiveMagazine.Service.csproj" />
<ProjectReference Include="..\QYZH.InteractiveMagazine.Infrastructure\QYZH.InteractiveMagazine.Infrastructure.csproj" />
<ProjectReference Include="..\QYZH.InteractiveMagazine.Common\QYZH.InteractiveMagazine.Common.csproj" />
<ProjectReference Include="..\QYZH.InteractiveMagazine.Models\QYZH.InteractiveMagazine.Models.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -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/"
}
}

View File

@ -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": "说服力"
}
]
}
]
}
}

View File

@ -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;
/// <summary>
/// 后台权限管理
/// </summary>
[ApiController]
[Route("api/[controller]")]
[ApiExplorerSettings(GroupName = nameof(ApiVersionEnum.Platform))]
public class PermissionController(IAdminPermissionService adminPermissionService) : BaseController
{
/// <summary>
/// 创建菜单
/// </summary>
[HttpPost("menus")]
public async Task<BaseResponse<AdminMenuOutput>> CreateMenuAsync([FromBody] AdminMenuInput input)
{
var result = await adminPermissionService.CreateMenuAsync(input);
return Success(result);
}
/// <summary>
/// 更新菜单
/// </summary>
[HttpPut("menus/{id}")]
public async Task<BaseResponse<AdminMenuOutput>> UpdateMenuAsync(long id, [FromBody] AdminMenuInput input)
{
var result = await adminPermissionService.UpdateMenuAsync(id, input);
return Success(result);
}
/// <summary>
/// 删除菜单
/// </summary>
[HttpDelete("menus/{id}")]
public async Task<BaseResponse<object>> DeleteMenuAsync(long id)
{
await adminPermissionService.DeleteMenuAsync(id);
return Success(new object());
}
/// <summary>
/// 获取菜单树
/// </summary>
[HttpPost("menus/tree")]
public async Task<BaseResponse<List<AdminMenuOutput>>> GetMenuTreeAsync([FromBody] AdminMenuQueryInput input)
{
var result = await adminPermissionService.GetMenuTreeAsync(input);
return Success(result);
}
/// <summary>
/// 获取当前管理员菜单树
/// </summary>
[HttpGet("menus/current")]
public async Task<BaseResponse<List<AdminMenuOutput>>> GetCurrentMenuTreeAsync()
{
var userId = GetCurrentUserId();
BusinessException.ThrowIf(userId == null, "未获取到用户信息", ResultCode.DENY);
var result = await adminPermissionService.GetAdminUserMenuTreeAsync(userId!.Value);
return Success(result);
}
/// <summary>
/// 创建角色
/// </summary>
[HttpPost("roles")]
public async Task<BaseResponse<AdminRoleOutput>> CreateRoleAsync([FromBody] AdminRoleInput input)
{
var result = await adminPermissionService.CreateRoleAsync(input);
return Success(result);
}
/// <summary>
/// 更新角色
/// </summary>
[HttpPut("roles/{id}")]
public async Task<BaseResponse<AdminRoleOutput>> UpdateRoleAsync(long id, [FromBody] AdminRoleInput input)
{
var result = await adminPermissionService.UpdateRoleAsync(id, input);
return Success(result);
}
/// <summary>
/// 删除角色
/// </summary>
[HttpDelete("roles/{id}")]
public async Task<BaseResponse<object>> DeleteRoleAsync(long id)
{
await adminPermissionService.DeleteRoleAsync(id);
return Success(new object());
}
/// <summary>
/// 获取角色详情
/// </summary>
[HttpGet("roles/{id}")]
public async Task<BaseResponse<AdminRoleOutput>> GetRoleByIdAsync(long id)
{
var result = await adminPermissionService.GetRoleByIdAsync(id);
return Success(result);
}
/// <summary>
/// 获取角色分页列表
/// </summary>
[HttpPost("roles/list")]
public async Task<BaseResponse<PageListModel<AdminRoleOutput>>> GetRoleListAsync([FromBody] AdminRoleQueryInput input)
{
var result = await adminPermissionService.GetRoleListAsync(input);
return Success(result);
}
/// <summary>
/// 分配角色菜单
/// </summary>
[HttpPut("roles/{roleId}/menus")]
public async Task<BaseResponse<object>> AssignRoleMenusAsync(long roleId, [FromBody] AssignRoleMenusInput input)
{
await adminPermissionService.AssignRoleMenusAsync(roleId, input);
return Success(new object());
}
/// <summary>
/// 分配管理员角色
/// </summary>
[HttpPut("users/{adminUserId}/roles")]
public async Task<BaseResponse<object>> AssignAdminUserRolesAsync(long adminUserId, [FromBody] AssignAdminUserRolesInput input)
{
await adminPermissionService.AssignAdminUserRolesAsync(adminUserId, input);
return Success(new object());
}
}

View File

@ -19,10 +19,22 @@ public class UserJournalQrCodeController(IUserJournalService userJournalService)
/// <param name="input">生成输入</param>
/// <returns>二维码记录</returns>
[HttpPost("add")]
public async Task<BaseResponse<UserJournalQrCodeOutput>> AddAsync([FromBody] CreateUserJournalQrCodeInput input)
public async Task<BaseResponse<CreateUserJournalQrCodeOutput>> AddAsync([FromBody] CreateUserJournalQrCodeInput input)
{
var result = await userJournalService.CreateQrCodeAsync(input, GetCurrentUserId() ?? 0);
return BaseResponse<UserJournalQrCodeOutput>.Success(result);
var result = await userJournalService.CreateQrCodesAsync(input, GetCurrentUserName() ?? "System");
return BaseResponse<CreateUserJournalQrCodeOutput>.Success(result);
}
/// <summary>
/// 根据期刊Id获取未绑定用户的二维码地址
/// </summary>
/// <param name="journalId">期刊Id</param>
/// <returns>未绑定二维码列表</returns>
[HttpGet("unbound/{journalId:long}")]
public async Task<BaseResponse<List<UserJournalQrCodeOutput>>> GetUnboundByJournalIdAsync(long journalId)
{
var result = await userJournalService.GetUnboundQrCodesByJournalIdAsync(journalId);
return BaseResponse<List<UserJournalQrCodeOutput>>.Success(result);
}
/// <summary>
@ -57,7 +69,7 @@ public class UserJournalQrCodeController(IUserJournalService userJournalService)
[HttpPost("delete")]
public async Task<BaseResponse<bool>> DeleteAsync([FromBody] DeleteUserJournalQrCodeInput input)
{
var result = await userJournalService.DeleteQrCodeAsync(input, GetCurrentUserId() ?? 0);
var result = await userJournalService.DeleteQrCodeAsync(input, GetCurrentUserName() ?? "System");
return BaseResponse<bool>.Success(result);
}
}

View File

@ -28,8 +28,8 @@ public class UsersController : BaseController
/// <summary>
/// 获取用户列表(分页)
/// </summary>
[HttpGet]
public async Task<BaseResponse<PageListModel<UsersOutput>>> GetList([FromQuery] UsersQueryInput input)
[HttpPost]
public async Task<BaseResponse<PageListModel<UsersOutput>>> GetList([FromBody] UsersQueryInput input)
{
return await _usersService.GetListAsync(input);
}
@ -46,8 +46,8 @@ public class UsersController : BaseController
/// <summary>
/// 分页查询用户积分记录
/// </summary>
[HttpGet("{id}/pointsRecords")]
public async Task<BaseResponse<PageListModel<PointsRecordOutput>>> GetUserPointsRecords(long id, [FromQuery] PointsRecordQueryInput input)
[HttpPost("{id}/pointsRecords")]
public async Task<BaseResponse<PageListModel<PointsRecordOutput>>> GetUserPointsRecords(long id, [FromBody] PointsRecordQueryInput input)
{
return await _usersService.GetUserPointsRecordsAsync(id, input);
}
@ -55,8 +55,8 @@ public class UsersController : BaseController
/// <summary>
/// 分页查询用户签到记录
/// </summary>
[HttpGet("{id}/checkInRecords")]
public async Task<BaseResponse<PageListModel<CheckInRecordOutput>>> GetUserCheckInRecords(long id, [FromQuery] PageQueryModel input)
[HttpPost("{id}/checkInRecords")]
public async Task<BaseResponse<PageListModel<CheckInRecordOutput>>> GetUserCheckInRecords(long id, [FromBody] PageQueryModel input)
{
return await _usersService.GetUserCheckInRecordsAsync(id, input);
}
@ -64,8 +64,8 @@ public class UsersController : BaseController
/// <summary>
/// 分页查询用户补偿任务
/// </summary>
[HttpGet("{id}/compensationTasks")]
public async Task<BaseResponse<PageListModel<CompensationTaskOutput>>> GetUserCompensationTasks(long id, [FromQuery] CompensationTaskQueryInput input)
[HttpPost("{id}/compensationTasks")]
public async Task<BaseResponse<PageListModel<CompensationTaskOutput>>> GetUserCompensationTasks(long id, [FromBody] CompensationTaskQueryInput input)
{
return await _usersService.GetUserCompensationTasksAsync(id, input);
}
@ -73,8 +73,8 @@ public class UsersController : BaseController
/// <summary>
/// 分页查询用户期刊列表
/// </summary>
[HttpGet("{id}/journals")]
public async Task<BaseResponse<PageListModel<UserJournalItemOutput>>> GetUserJournals(long id, [FromQuery] UserJournalQueryInput input)
[HttpPost("{id}/journals")]
public async Task<BaseResponse<PageListModel<UserJournalItemOutput>>> GetUserJournals(long id, [FromBody] UserJournalQueryInput input)
{
return await _usersService.GetUserJournalsAsync(id, input);
}

View File

@ -90,7 +90,7 @@ builder.Services.AddSwaggerGen(option =>
var modelXml = Path.Combine(AppContext.BaseDirectory, $"QYZH.InteractiveMagazine.Models.xml");
Enum.GetValues<ApiVersionEnum>().ToList().ForEach(version =>
Enum.GetValues<ApiVersionEnum>().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<ApiVersionEnum>().OrderBy(e => e).ToList().ForEach(version =>
Enum.GetValues<ApiVersionEnum>().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();

View File

@ -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";
@ -42,6 +47,7 @@ public class JournalTaskReceiveConsumer(
{
PropertyNameCaseInsensitive = true
}) ?? throw new InvalidOperationException("期刊任务消息内容为空");
data.Normalize();
if (data.Questions == null || data.Questions.Length == 0)
{
@ -54,17 +60,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<JournalPageTask>()
.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<JournalPageTaskAnswer>()
.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<JournalPage>()
.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<JournalPage>()
.Where(p => pageIds.Contains(p.Id) && !p.IsDeleted)
.ToListAsync(cancellationToken);
var pageMap = pages.ToDictionary(p => p.Id);
var existingAnswers = await client.Queryable<JournalPageTaskUserAnswer>()
.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<JournalQuestionContext>();
var answerContexts = new List<JournalAnswerContext>();
foreach (var question in data.Questions)
{
@ -80,13 +107,55 @@ public class JournalTaskReceiveConsumer(
continue;
}
referenceAnswerMap.TryGetValue(task.Id, out var taskReferenceAnswers);
var scoreResult = await ScoreQuestionAsync(task, question, taskReferenceAnswers ?? [], cancellationToken);
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, question, task, page, scoreResult, GetCompletionThreshold()),
question,
task,
scoreResult));
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 +217,8 @@ public class JournalTaskReceiveConsumer(
}
private async Task<JournalAnswerScoreResult> ScoreQuestionAsync(
JournalPageTask task,
Question question,
List<JournalPageTaskAnswer> referenceAnswers,
JournalScoreUnit scoreUnit,
Dictionary<long, List<JournalPageTaskAnswer>> referenceAnswerMap,
CancellationToken cancellationToken)
{
var apiKey = configuration["AiChat:ApiKey"];
@ -158,30 +226,33 @@ public class JournalTaskReceiveConsumer(
var model = configuration["AiChat:Model"];
var timeoutSeconds = configuration.GetValue<int>("AiChat:TimeoutSeconds");
var maxTokens = configuration.GetValue<int>("AiChat:MaxTokens");
var temperature = configuration.GetValue<double>("AiChat:Temperature");
var temperature = configuration.GetValue<double?>("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<object>
{
new
{
type = "text",
text = BuildScorePrompt(task, question, answerImages.Count, referenceAnswers, referenceAnswerImages.Count)
text = BuildScorePrompt(scoreUnit, referenceAnswerMap)
}
};
var totalAnswerImageCount = 0;
foreach (var context in scoreUnit.Questions)
{
var answerImages = await BuildAnswerImageContentsAsync(context.Question, cancellationToken);
totalAnswerImageCount += answerImages.Count;
content.Add(new
{
type = "text",
text = $"以下为任务 {context.Task.Id} 的学生作答图片,共 {answerImages.Count} 张。"
});
foreach (var answerImage in answerImages)
{
content.Add(new
@ -190,13 +261,26 @@ public class JournalTaskReceiveConsumer(
image_url = new { url = answerImage.DataUrl }
});
}
}
if (referenceAnswerImages.Count > 0)
if (totalAnswerImageCount == 0)
{
throw new InvalidOperationException($"评分单元 {scoreUnit.GroupId} 缺少答案图片");
}
foreach (var context in scoreUnit.Questions)
{
referenceAnswerMap.TryGetValue(context.Task.Id, out var referenceAnswers);
var referenceAnswerImages = await BuildReferenceAnswerImageContentsAsync(context.Task.Id, referenceAnswers ?? [], cancellationToken);
if (referenceAnswerImages.Count == 0)
{
continue;
}
content.Add(new
{
type = "text",
text = $"以下为参考答案图片,共 {referenceAnswerImages.Count} 张。参考答案不是必有评分时以题目Prompt和学生答案为主。"
text = $"以下为任务 {context.Task.Id} 的参考答案图片,共 {referenceAnswerImages.Count} 张。参考答案不是必有评分时以题目Prompt和学生答案为主。"
});
foreach (var referenceAnswerImage in referenceAnswerImages)
@ -226,7 +310,7 @@ public class JournalTaskReceiveConsumer(
}
},
max_tokens = maxTokens > 0 ? maxTokens : 2000,
temperature = temperature > 0 ? temperature : 0.2,
temperature = temperature is >= 0 ? temperature.Value : 0.1,
stream = false,
response_format = new { type = "json_object" }
};
@ -236,35 +320,46 @@ public class JournalTaskReceiveConsumer(
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
var responseContent = await SendAiScoreRequestWithRetryAsync(
task.Id,
var semaphore = GetAiSemaphore();
await semaphore.WaitAsync(cancellationToken);
try
{
var scoreResult = await SendAiScoreRequestWithRetryAsync(
scoreUnit.GroupId,
$"{baseUrl.TrimEnd('/')}/chat/completions",
apiKey,
requestJson,
timeoutSeconds > 0 ? timeoutSeconds : 300,
cancellationToken);
var resultJson = ExtractAssistantContent(responseContent);
var scoreResult = ParseScoreResult(resultJson);
scoreResult.Result = TrimResult(scoreResult.Result);
return scoreResult;
}
finally
{
semaphore.Release();
}
}
private static string BuildScorePrompt(
JournalPageTask task,
Question question,
int answerImageCount,
List<JournalPageTaskAnswer> referenceAnswers,
int referenceAnswerImageCount)
JournalScoreUnit scoreUnit,
Dictionary<long, List<JournalPageTaskAnswer>> referenceAnswerMap)
{
var referenceAnswerTexts = referenceAnswers
var referenceAnswerTexts = scoreUnit.Questions
.SelectMany(q =>
{
referenceAnswerMap.TryGetValue(q.Task.Id, out var answers);
return answers ?? [];
})
.Select(a => a.Answer?.Trim())
.Where(a => !string.IsNullOrWhiteSpace(a))
.Distinct()
.ToList();
var prompt = new StringBuilder();
prompt.AppendLine(scoreUnit.Questions.Count > 1
? "这是同一跨页题目的多页作答,请综合全部学生作答图片评分。"
: "这是单页题目作答,请根据学生作答图片评分。");
prompt.AppendLine("参考答案不是必有;若无参考答案,以题目 Prompt 和学生答案为准评分。");
prompt.AppendLine($"参考答案图片数量:{referenceAnswerImageCount}");
if (referenceAnswerTexts.Count > 0)
{
prompt.AppendLine("参考答案文本:");
@ -278,21 +373,27 @@ public class JournalTaskReceiveConsumer(
prompt.AppendLine("参考答案文本:无");
}
prompt.AppendLine();
prompt.AppendLine("请根据题目评分 Prompt 和学生答案图片进行评分。");
prompt.AppendLine("题目信息:");
foreach (var context in scoreUnit.Questions)
{
prompt.AppendLine($"- TaskId{context.Task.Id}");
prompt.AppendLine($" 页码:{context.Page?.PageNum ?? 0}");
prompt.AppendLine($" 题号:{context.Task.No}");
prompt.AppendLine($" 题目内容:{context.Task.Task}");
prompt.AppendLine($" 评分Prompt{context.Task.Prompt}");
prompt.AppendLine($" 成长值上限:{context.Task.GrowthPoint}");
prompt.AppendLine($" 积分上限:{context.Task.Points}");
prompt.AppendLine($" 理解力上限:{context.Task.Comprehension}");
prompt.AppendLine($" 判断力上限:{context.Task.Judgment}");
prompt.AppendLine($" 表达力上限:{context.Task.Expression}");
prompt.AppendLine($" 说服力上限:{context.Task.Persuasiveness}");
}
prompt.AppendLine();
prompt.AppendLine($"题目内容:{task.Task}");
prompt.AppendLine("评分Prompt");
prompt.AppendLine(task.Prompt);
prompt.AppendLine();
prompt.AppendLine("题目配置:");
prompt.AppendLine($"- 成长值上限:{task.GrowthPoint}");
prompt.AppendLine($"- 积分上限:{task.Points}");
prompt.AppendLine($"- 理解力上限:{task.Comprehension}");
prompt.AppendLine($"- 判断力上限:{task.Judgment}");
prompt.AppendLine($"- 表达力上限:{task.Expression}");
prompt.AppendLine($"- 说服力上限:{task.Persuasiveness}");
prompt.AppendLine();
prompt.AppendLine($"学生答案图片数量:{answerImageCount}");
prompt.AppendLine("评分要求:");
prompt.AppendLine("- 不得超过题目配置中的各项上限。");
prompt.AppendLine("- 看不清、缺页、无法识别或答案明显不完整时,降低 Completion不要猜测高分。");
prompt.AppendLine("- Completion 表示作答完整度,范围 0-100。");
prompt.AppendLine("- Result 返回 50 字内中文评语。");
prompt.AppendLine();
prompt.AppendLine("只返回如下 JSON 字段:");
prompt.AppendLine("{");
@ -408,7 +509,7 @@ public class JournalTaskReceiveConsumer(
return result;
}
private async Task<string> SendAiScoreRequestWithRetryAsync(
private async Task<JournalAnswerScoreResult> SendAiScoreRequestWithRetryAsync(
long taskId,
string requestUrl,
string apiKey,
@ -446,22 +547,37 @@ public class JournalTaskReceiveConsumer(
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
if (response.IsSuccessStatusCode)
{
return responseContent;
var resultJson = ExtractAssistantContent(responseContent);
return ParseScoreResult(resultJson);
}
logger.LogWarning("AI评分调用失败TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}, StatusCode: {StatusCode}, Response: {Response}",
taskId, attempt, maxRetryCount, response.StatusCode, responseContent);
if (!ShouldRetry(response.StatusCode) || attempt == maxRetryCount)
if (!ShouldRetry(response.StatusCode))
{
throw new NonRetryAiScoreException($"AI评分调用失败{response.StatusCode},响应:{responseContent}");
}
if (attempt == maxRetryCount)
{
throw new InvalidOperationException($"AI评分调用失败{response.StatusCode},响应:{responseContent}");
}
}
catch (OperationCanceledException)
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex) when (attempt < maxRetryCount)
catch (OperationCanceledException ex) when (attempt < maxRetryCount)
{
lastException = ex;
logger.LogWarning(ex, "AI评分调用超时准备重试TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}", taskId, attempt, maxRetryCount);
}
catch (OperationCanceledException ex)
{
throw new InvalidOperationException($"AI评分调用超时TaskId: {taskId}", ex);
}
catch (Exception ex) when (attempt < maxRetryCount && ex is not NonRetryAiScoreException)
{
lastException = ex;
logger.LogWarning(ex, "AI评分调用异常准备重试TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}", taskId, attempt, maxRetryCount);
@ -536,6 +652,7 @@ public class JournalTaskReceiveConsumer(
private static JournalAnswerScoreResult ParseScoreResult(string resultJson)
{
var cleanedJson = CleanJsonContent(resultJson);
EnsureRequiredScoreFields(cleanedJson);
var result = JsonSerializer.Deserialize<JournalAnswerScoreResult>(cleanedJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
@ -572,8 +689,219 @@ public class JournalTaskReceiveConsumer(
return text.Trim();
}
private static void EnsureRequiredScoreFields(string json)
{
using var document = JsonDocument.Parse(json);
var requiredFields = new[]
{
nameof(JournalAnswerScoreResult.Score),
nameof(JournalAnswerScoreResult.GrowthPoint),
nameof(JournalAnswerScoreResult.Points),
nameof(JournalAnswerScoreResult.Comprehension),
nameof(JournalAnswerScoreResult.Judgment),
nameof(JournalAnswerScoreResult.Expression),
nameof(JournalAnswerScoreResult.Persuasiveness),
nameof(JournalAnswerScoreResult.Completion),
nameof(JournalAnswerScoreResult.Result)
};
foreach (var field in requiredFields)
{
if (!document.RootElement.EnumerateObject().Any(p => string.Equals(p.Name, field, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException($"AI评分结果缺少字段{field}");
}
}
}
private static JournalAnswerScoreResult NormalizeScoreResult(JournalAnswerScoreResult scoreResult, JournalPageTask task)
{
var scoreMax = task.Comprehension + task.Judgment + task.Expression + task.Persuasiveness;
if (scoreMax <= 0)
{
scoreMax = 100;
}
return new JournalAnswerScoreResult
{
Score = Clamp(scoreResult.Score, 0, scoreMax),
GrowthPoint = (int)Clamp(scoreResult.GrowthPoint, 0, task.GrowthPoint),
Points = (int)Clamp(scoreResult.Points, 0, task.Points),
Comprehension = Clamp(scoreResult.Comprehension, 0, task.Comprehension),
Judgment = Clamp(scoreResult.Judgment, 0, task.Judgment),
Expression = Clamp(scoreResult.Expression, 0, task.Expression),
Persuasiveness = Clamp(scoreResult.Persuasiveness, 0, task.Persuasiveness),
Completion = Clamp(scoreResult.Completion, 0, 100),
Result = TrimResult(scoreResult.Result)
};
}
private static float Clamp(float value, float min, float max)
{
if (float.IsNaN(value) || float.IsInfinity(value))
{
return min;
}
if (max < min)
{
max = min;
}
return Math.Min(Math.Max(value, min), max);
}
private static JournalAnswerScoreResult BuildFailureScoreResult()
{
return new JournalAnswerScoreResult
{
Completion = 0,
Result = AiProcessingMessage
};
}
private static List<JournalScoreUnit> BuildScoreUnits(List<JournalQuestionContext> contexts)
{
return contexts
.GroupBy(c => c.Task.GroupId > 0 ? c.Task.GroupId : c.Task.Id)
.Select(g => new JournalScoreUnit(
g.Key,
g.OrderBy(c => c.Page?.PageNum ?? 0)
.ThenBy(c => ParseTaskNo(c.Task.No))
.ThenBy(c => c.Task.Id)
.ToList()))
.ToList();
}
private static JournalQuestionContext? BuildPersistedContext(
JournalScoreUnit scoreUnit,
Dictionary<long, JournalPageTask> persistedTaskMap,
Dictionary<long, JournalPage> pageMap)
{
if (scoreUnit.Questions.Count == 0)
{
return null;
}
var sourceContext = scoreUnit.Questions.FirstOrDefault(q => q.Task.Id == scoreUnit.GroupId)
?? scoreUnit.Questions.First();
var persistedTask = persistedTaskMap.TryGetValue(scoreUnit.GroupId, out var task)
? task
: sourceContext.Task;
pageMap.TryGetValue(persistedTask.JournalPageId, out var persistedPage);
return new JournalQuestionContext(sourceContext.Question, persistedTask, persistedPage);
}
private static int ParseTaskNo(string? taskNo)
{
return int.TryParse(taskNo, out var no) ? no : int.MaxValue;
}
private static bool IsCompletedSameAnswer(
JournalScoreUnit scoreUnit,
JournalPageTask persistedTask,
QuestionData data,
Dictionary<long, JournalPageTaskUserAnswer> existingAnswerMap)
{
return scoreUnit.Questions.Count > 0
&& existingAnswerMap.TryGetValue(persistedTask.Id, out var existing)
&& existing.Status == (int)UserAnswerStatusEnum.Complete
&& string.Equals(existing.PageAnswerUrl ?? string.Empty, data.PageAnswerUrl ?? string.Empty, StringComparison.Ordinal)
&& string.Equals(existing.AnswerUrl ?? string.Empty, SerializeAnswerUrls(scoreUnit), StringComparison.Ordinal)
&& existing.AnswerStartTime == GetAnswerStartTime(scoreUnit)
&& existing.AnswerEndTime == GetAnswerEndTime(scoreUnit);
}
private SemaphoreSlim GetAiSemaphore()
{
var maxConcurrency = configuration.GetValue<int>("AiChat:MaxConcurrency");
if (maxConcurrency <= 0)
{
maxConcurrency = DefaultAiMaxConcurrency;
}
lock (AiSemaphoreLock)
{
if (aiSemaphore == null || aiSemaphoreLimit != maxConcurrency)
{
aiSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
aiSemaphoreLimit = maxConcurrency;
}
return aiSemaphore;
}
}
private void LogSkippedGroupTasks(JournalScoreUnit scoreUnit, JournalPageTask persistedTask)
{
var skippedTaskIds = scoreUnit.Questions
.Select(q => q.Task.Id)
.Where(id => id != persistedTask.Id)
.Distinct()
.ToList();
if (skippedTaskIds.Count == 0)
{
return;
}
logger.LogInformation("期刊跨页题评分结果仅保存到主任务GroupId: {GroupId}, PersistedTaskId: {PersistedTaskId}, SkippedTaskIds: {SkippedTaskIds}",
scoreUnit.GroupId, persistedTask.Id, string.Join(",", skippedTaskIds));
}
private static string SerializeAnswerUrls(JournalScoreUnit scoreUnit)
{
var answerUrls = scoreUnit.Questions
.SelectMany(q => q.Question.AnswerUrl ?? [])
.Where(url => !string.IsNullOrWhiteSpace(url))
.Distinct()
.ToArray();
return JsonSerializer.Serialize(answerUrls);
}
private static DateTime GetAnswerStartTime(JournalScoreUnit scoreUnit)
{
var startTimes = scoreUnit.Questions
.Select(q => q.Question.AnswerStartTime)
.Where(t => t != default)
.ToList();
return startTimes.Count == 0 ? default : startTimes.Min();
}
private static DateTime GetAnswerEndTime(JournalScoreUnit scoreUnit)
{
var endTimes = scoreUnit.Questions
.Select(q => q.Question.AnswerEndTime)
.Where(t => t != default)
.ToList();
return endTimes.Count == 0 ? default : endTimes.Max();
}
private static int GetAnswerSeconds(JournalScoreUnit scoreUnit)
{
return scoreUnit.Questions.Sum(q => Math.Max(0, q.Question.AnswerTime));
}
private static int GetBreakCount(JournalScoreUnit scoreUnit)
{
return scoreUnit.Questions.Sum(q => Math.Max(0, q.Question.BreakCount));
}
private static string SerializeBreakTimes(JournalScoreUnit scoreUnit)
{
var breakTimes = scoreUnit.Questions
.SelectMany(q => q.Question.BreakTimes ?? [])
.ToList();
return JsonSerializer.Serialize(breakTimes);
}
private static JournalPageTaskUserAnswer BuildAnswerEntity(
QuestionData data,
JournalScoreUnit scoreUnit,
Question question,
JournalPageTask task,
JournalPage? page,
@ -603,12 +931,12 @@ public class JournalTaskReceiveConsumer(
Expression = Math.Max(0, scoreResult.Expression),
Persuasiveness = Math.Max(0, scoreResult.Persuasiveness),
QuestionAnswerUrl = question.Url,
AnswerUrl = JsonSerializer.Serialize(question.AnswerUrl ?? []),
AnswerUrl = SerializeAnswerUrls(scoreUnit),
PageAnswerUrl = data.PageAnswerUrl,
Revision = 0,
AnswerStartTime = question.AnswerStartTime,
AnswerEndTime = question.AnswerEndTime,
AnswerSeconds = question.AnswerTime,
AnswerStartTime = GetAnswerStartTime(scoreUnit),
AnswerEndTime = GetAnswerEndTime(scoreUnit),
AnswerSeconds = GetAnswerSeconds(scoreUnit),
ImageRecognition = 0,
JournalPageNum = page?.PageNum ?? 0,
Modify = 0,
@ -618,8 +946,8 @@ public class JournalTaskReceiveConsumer(
Type = task.Type.ToString(),
DotPageNo = page?.PageNo ?? string.Empty,
PageAnswerDotUrl = string.Empty,
BreakCount = question.BreakCount,
BreakTimes = JsonSerializer.Serialize(question.BreakTimes ?? []),
BreakCount = GetBreakCount(scoreUnit),
BreakTimes = SerializeBreakTimes(scoreUnit),
AssignmentStatus = answerStatus.ToString(),
Status = (int)answerStatus,
CreatedBy = data.UserId.ToString(),
@ -766,11 +1094,26 @@ public class QuestionData
/// </summary>
public long UserId { get; set; }
/// <summary>
/// 学生ID
/// </summary>
public long StudentId { get; set; }
/// <summary>
/// 期刊ID
/// </summary>
public long JournalId { get; set; }
/// <summary>
/// 作业ID
/// </summary>
public long HomeworkId { get; set; }
/// <summary>
/// 书籍ID
/// </summary>
public long BookId { get; set; }
/// <summary>
/// 页ID
/// </summary>
@ -790,6 +1133,22 @@ public class QuestionData
/// 创建时间
/// </summary>
public DateTime CreatedTime { get; set; }
/// <summary>
/// 兼容新版作答消息字段
/// </summary>
public void Normalize()
{
if (UserId <= 0)
{
UserId = StudentId;
}
if (JournalId <= 0)
{
JournalId = BookId > 0 ? BookId : HomeworkId;
}
}
}
/// <summary>
@ -911,4 +1270,18 @@ public record JournalAnswerContext(
JournalPageTask Task,
JournalAnswerScoreResult ScoreResult);
public record JournalQuestionContext(
Question Question,
JournalPageTask Task,
JournalPage? Page);
public record JournalScoreUnit(
long GroupId,
List<JournalQuestionContext> Questions);
public record AnswerImageContent(string DataUrl);
/// <summary>
/// AI评分不可重试异常。
/// </summary>
public class NonRetryAiScoreException(string message) : Exception(message);

View File

@ -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;
/// <summary>
/// 期刊二维码生成消费者
/// </summary>
public class UserJournalQrCodeGenerateConsumer(
ILogger<UserJournalQrCodeGenerateConsumer> 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<GenerateUserJournalQrCodeMessage>(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<ISqlSugarClient>();
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<GenerateUserJournalQrCodeMessage>(Encoding.UTF8.GetString(message), new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (data?.RecordIds.Count > 0)
{
using var scope = scopeFactory.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
var operatorName = string.IsNullOrWhiteSpace(data.OperatorName) ? "System" : data.OperatorName;
await client.Updateable<UserJournal>()
.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<UserJournal>()
.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<UserJournal>()
.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<UserJournal>()
.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 });
}
}

View File

@ -77,6 +77,7 @@ builder.Services.AddRabbitMQ(builder.Configuration);
// 注册队列消费者(新增消费者只需实现 IQueueConsumer 并在此注册)
builder.Services.AddScoped<IQueueConsumer, JournalTaskReceiveConsumer>();
builder.Services.AddScoped<IQueueConsumer, UserJournalQrCodeGenerateConsumer>();
// 注册消费者后台服务
builder.Services.AddHostedService<RabbitMQHostedService>();

View File

@ -22,4 +22,10 @@
<PackageReference Include="Yitter.IdGenerator" Version="1.0.14" />
</ItemGroup>
<ItemGroup>
<None Update="Dockerfile">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -12,7 +12,8 @@
"Port": 5672,
"UserName": "smartschool",
"Password": "@ss%&*otz%d*pq2S",
"VirtualHost": "InteractiveMagazine"
"VirtualHost": "InteractiveMagazine",
"PrefetchCount": 1
},
"Serilog": {
"MinimumLevel": {
@ -55,7 +56,8 @@
"Model": "qwen2.5vl:7b",
"TimeoutSeconds": 300,
"MaxTokens": 2000,
"Temperature": 0.5,
"Temperature": 0.1,
"MaxConcurrency": 1,
"ScoreMaxRetryCount": 3,
"ScoreRetryDelayMilliseconds": 1000,
"MaxImageBytes": 10485760,

View File

@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.9",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@ -9,5 +9,6 @@
<Project Path="QYZH.InteractiveMagazine.Repository/QYZH.InteractiveMagazine.Repository.csproj" />
<Project Path="QYZH.InteractiveMagazine.Service/QYZH.InteractiveMagazine.Service.csproj" />
<Project Path="QYZH.InteractiveMagazine.WebApi/QYZH.InteractiveMagazine.WebApi.csproj" />
<Project Path="QYZH.InteractiveMagazine.WeChatApi/QYZH.InteractiveMagazine.WeChatApi.csproj" />
<Project Path="QYZH.InteractiveMagazine.WorkService/QYZH.InteractiveMagazine.WorkService.csproj" />
</Solution>