diff --git a/QYZH.InteractiveMagazine.Common/Helpers/QrCodeHelper.cs b/QYZH.InteractiveMagazine.Common/Helpers/QrCodeHelper.cs new file mode 100644 index 0000000..f8e8ebb --- /dev/null +++ b/QYZH.InteractiveMagazine.Common/Helpers/QrCodeHelper.cs @@ -0,0 +1,473 @@ +using System.IO.Compression; +using System.Text; + +namespace QYZH.InteractiveMagazine.Common.Helpers; + +/// +/// 二维码帮助类 +/// +public static class QrCodeHelper +{ + private const int Version = 4; + private const int Size = Version * 4 + 17; + private const int DataCodewordCount = 80; + private const int ErrorCorrectionCodewordCount = 20; + private const int QuietZone = 4; + private const int Scale = 10; + private static readonly int[] AlignmentPatternPositions = [6, 26]; + + /// + /// 生成二维码PNG图片字节 + /// + /// 二维码内容 + /// PNG图片字节 + public static byte[] GeneratePng(string content) + { + var dataCodewords = CreateDataCodewords(content); + var allCodewords = dataCodewords.Concat(CreateErrorCorrectionCodewords(dataCodewords)).ToArray(); + var modules = BuildModules(allCodewords); + var imageSize = (Size + QuietZone * 2) * Scale; + var pixels = new byte[imageSize * imageSize * 4]; + + for (var i = 0; i < pixels.Length; i += 4) + { + pixels[i] = 255; + pixels[i + 1] = 255; + pixels[i + 2] = 255; + pixels[i + 3] = 255; + } + + for (var y = 0; y < Size; y++) + { + for (var x = 0; x < Size; x++) + { + if (modules[y, x]) + { + FillModule(pixels, imageSize, x + QuietZone, y + QuietZone); + } + } + } + + return EncodePng(imageSize, imageSize, pixels); + } + + private static byte[] CreateDataCodewords(string content) + { + var bytes = Encoding.UTF8.GetBytes(content); + if (bytes.Length > 78) + { + throw new InvalidOperationException("二维码内容过长"); + } + + var bits = new List(); + AppendBits(bits, 0b0100, 4); + AppendBits(bits, bytes.Length, 8); + foreach (var value in bytes) + { + AppendBits(bits, value, 8); + } + + var remaining = DataCodewordCount * 8 - bits.Count; + AppendBits(bits, 0, Math.Min(4, remaining)); + + while (bits.Count % 8 != 0) + { + bits.Add(false); + } + + var data = BitsToBytes(bits); + var pad = true; + while (data.Count < DataCodewordCount) + { + data.Add((byte)(pad ? 0xEC : 0x11)); + pad = !pad; + } + + return data.ToArray(); + } + + private static bool[,] BuildModules(byte[] codewords) + { + var modules = new bool[Size, Size]; + var reserved = new bool[Size, Size]; + + AddFinder(modules, reserved, 0, 0); + AddFinder(modules, reserved, Size - 7, 0); + AddFinder(modules, reserved, 0, Size - 7); + AddTimingPatterns(modules, reserved); + AddAlignmentPatterns(modules, reserved); + ReserveFormatAreas(reserved); + + modules[Version * 4 + 9, 8] = true; + reserved[Version * 4 + 9, 8] = true; + + AddDataModules(modules, reserved, codewords); + AddFormatBits(modules); + + return modules; + } + + private static void AddFinder(bool[,] modules, bool[,] reserved, int left, int top) + { + for (var y = -1; y <= 7; y++) + { + for (var x = -1; x <= 7; x++) + { + var px = left + x; + var py = top + y; + if (px < 0 || py < 0 || px >= Size || py >= Size) + { + continue; + } + + reserved[py, px] = true; + modules[py, px] = x >= 0 && x <= 6 && y >= 0 && y <= 6 + && (x == 0 || x == 6 || y == 0 || y == 6 || (x >= 2 && x <= 4 && y >= 2 && y <= 4)); + } + } + } + + private static void AddTimingPatterns(bool[,] modules, bool[,] reserved) + { + for (var i = 8; i < Size - 8; i++) + { + var isDark = i % 2 == 0; + modules[6, i] = isDark; + modules[i, 6] = isDark; + reserved[6, i] = true; + reserved[i, 6] = true; + } + } + + private static void AddAlignmentPatterns(bool[,] modules, bool[,] reserved) + { + foreach (var centerY in AlignmentPatternPositions) + { + foreach (var centerX in AlignmentPatternPositions) + { + if (reserved[centerY, centerX]) + { + continue; + } + + for (var y = -2; y <= 2; y++) + { + for (var x = -2; x <= 2; x++) + { + var px = centerX + x; + var py = centerY + y; + reserved[py, px] = true; + modules[py, px] = Math.Max(Math.Abs(x), Math.Abs(y)) != 1; + } + } + } + } + } + + private static void ReserveFormatAreas(bool[,] reserved) + { + for (var i = 0; i <= 8; i++) + { + reserved[8, i] = true; + reserved[i, 8] = true; + } + + for (var i = 0; i < 8; i++) + { + reserved[8, Size - 1 - i] = true; + } + + for (var i = 0; i < 7; i++) + { + reserved[Size - 1 - i, 8] = true; + } + } + + private static void AddDataModules(bool[,] modules, bool[,] reserved, byte[] codewords) + { + var bits = new List(); + foreach (var codeword in codewords) + { + AppendBits(bits, codeword, 8); + } + + var bitIndex = 0; + var upward = true; + for (var right = Size - 1; right >= 1; right -= 2) + { + if (right == 6) + { + right--; + } + + for (var i = 0; i < Size; i++) + { + var y = upward ? Size - 1 - i : i; + for (var j = 0; j < 2; j++) + { + var x = right - j; + if (reserved[y, x]) + { + continue; + } + + var bit = bitIndex < bits.Count && bits[bitIndex++]; + if ((x + y) % 2 == 0) + { + bit = !bit; + } + + modules[y, x] = bit; + } + } + + upward = !upward; + } + } + + private static void AddFormatBits(bool[,] modules) + { + var format = GetFormatBits(); + + for (var i = 0; i <= 5; i++) + { + modules[i, 8] = GetBit(format, i); + } + + modules[7, 8] = GetBit(format, 6); + modules[8, 8] = GetBit(format, 7); + modules[8, 7] = GetBit(format, 8); + + for (var i = 9; i < 15; i++) + { + modules[8, 14 - i] = GetBit(format, i); + } + + for (var i = 0; i < 8; i++) + { + modules[8, Size - 1 - i] = GetBit(format, i); + } + + for (var i = 8; i < 15; i++) + { + modules[Size - 15 + i, 8] = GetBit(format, i); + } + + modules[Size - 8, 8] = true; + } + + private static int GetFormatBits() + { + const int errorCorrectionLevelBits = 1; + const int mask = 0; + var data = (errorCorrectionLevelBits << 3) | mask; + var value = data << 10; + const int generator = 0x537; + + for (var i = 14; i >= 10; i--) + { + if (((value >> i) & 1) != 0) + { + value ^= generator << (i - 10); + } + } + + return ((data << 10) | (value & 0x3FF)) ^ 0x5412; + } + + private static byte[] CreateErrorCorrectionCodewords(byte[] data) + { + var generator = CreateGeneratorPolynomial(ErrorCorrectionCodewordCount); + var result = new byte[ErrorCorrectionCodewordCount]; + + foreach (var b in data) + { + var factor = b ^ result[0]; + Array.Copy(result, 1, result, 0, result.Length - 1); + result[^1] = 0; + + for (var i = 0; i < result.Length; i++) + { + result[i] ^= GaloisMultiply(generator[i + 1], factor); + } + } + + return result; + } + + private static byte[] CreateGeneratorPolynomial(int degree) + { + var result = new List { 1 }; + for (var i = 0; i < degree; i++) + { + var next = new byte[result.Count + 1]; + for (var j = 0; j < result.Count; j++) + { + next[j] ^= result[j]; + next[j + 1] ^= GaloisMultiply(result[j], GaloisPower(i)); + } + + result = next.ToList(); + } + + return result.ToArray(); + } + + private static byte GaloisPower(int exponent) + { + var value = 1; + for (var i = 0; i < exponent; i++) + { + value <<= 1; + if ((value & 0x100) != 0) + { + value ^= 0x11D; + } + } + + return (byte)value; + } + + private static byte GaloisMultiply(int x, int y) + { + var result = 0; + while (y != 0) + { + if ((y & 1) != 0) + { + result ^= x; + } + + x <<= 1; + if ((x & 0x100) != 0) + { + x ^= 0x11D; + } + + y >>= 1; + } + + return (byte)result; + } + + private static void AppendBits(List bits, int value, int count) + { + for (var i = count - 1; i >= 0; i--) + { + bits.Add(((value >> i) & 1) != 0); + } + } + + private static List BitsToBytes(List bits) + { + var result = new List(); + for (var i = 0; i < bits.Count; i += 8) + { + var value = 0; + for (var j = 0; j < 8; j++) + { + value = (value << 1) | (bits[i + j] ? 1 : 0); + } + + result.Add((byte)value); + } + + return result; + } + + private static bool GetBit(int value, int index) + { + return ((value >> index) & 1) != 0; + } + + private static void FillModule(byte[] pixels, int imageSize, int moduleX, int moduleY) + { + var startX = moduleX * Scale; + var startY = moduleY * Scale; + + for (var y = 0; y < Scale; y++) + { + for (var x = 0; x < Scale; x++) + { + var index = ((startY + y) * imageSize + startX + x) * 4; + pixels[index] = 0; + pixels[index + 1] = 0; + pixels[index + 2] = 0; + pixels[index + 3] = 255; + } + } + } + + private static byte[] EncodePng(int width, int height, byte[] rgba) + { + using var output = new MemoryStream(); + WriteUInt(output, 0x89504E47); + WriteUInt(output, 0x0D0A1A0A); + + using (var ihdr = new MemoryStream()) + { + WriteUInt(ihdr, (uint)width); + WriteUInt(ihdr, (uint)height); + ihdr.WriteByte(8); + ihdr.WriteByte(6); + ihdr.WriteByte(0); + ihdr.WriteByte(0); + ihdr.WriteByte(0); + WriteChunk(output, "IHDR", ihdr.ToArray()); + } + + var stride = width * 4; + using (var raw = new MemoryStream()) + { + for (var y = 0; y < height; y++) + { + raw.WriteByte(0); + raw.Write(rgba, y * stride, stride); + } + + using var compressed = new MemoryStream(); + using (var zlib = new ZLibStream(compressed, CompressionLevel.SmallestSize, true)) + { + raw.Position = 0; + raw.CopyTo(zlib); + } + + WriteChunk(output, "IDAT", compressed.ToArray()); + } + + WriteChunk(output, "IEND", []); + return output.ToArray(); + } + + private static void WriteChunk(Stream stream, string type, byte[] data) + { + WriteUInt(stream, (uint)data.Length); + var typeBytes = Encoding.ASCII.GetBytes(type); + stream.Write(typeBytes); + stream.Write(data); + WriteUInt(stream, Crc32(typeBytes.Concat(data).ToArray())); + } + + private static void WriteUInt(Stream stream, uint value) + { + stream.WriteByte((byte)(value >> 24)); + stream.WriteByte((byte)(value >> 16)); + stream.WriteByte((byte)(value >> 8)); + stream.WriteByte((byte)value); + } + + private static uint Crc32(byte[] bytes) + { + var crc = 0xFFFFFFFFu; + foreach (var b in bytes) + { + crc ^= b; + for (var i = 0; i < 8; i++) + { + crc = (crc & 1) == 1 ? (crc >> 1) ^ 0xEDB88320u : crc >> 1; + } + } + + return ~crc; + } +} diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs index 6660c26..909874d 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs @@ -17,10 +17,6 @@ public class BindJournalInput /// public long Id { get; set; } - /// - /// 关联类型: Read(已读), Favorite(收藏), Subscribe(订阅),默认 Subscribe - /// - public string Type { get; set; } = UserJournalTypeEnum.Subscribe.ToString(); } /// @@ -121,10 +117,6 @@ public class CreateUserJournalQrCodeInput /// public long JournalId { get; set; } - /// - /// 关联类型: Read, Favorite, Subscribe,默认 Subscribe - /// - public string Type { get; set; } = UserJournalTypeEnum.Subscribe.ToString(); } /// @@ -201,7 +193,7 @@ public class UserJournalQrCodeOutput /// /// 二维码内容 /// - public string QrCodeContent { get; set; } = string.Empty; + public string QrCodeUrl { get; set; } = string.Empty; /// /// 创建时间 diff --git a/QYZH.InteractiveMagazine.Models/Entity/UserJournal.cs b/QYZH.InteractiveMagazine.Models/Entity/UserJournal.cs index 611bce8..196b884 100644 --- a/QYZH.InteractiveMagazine.Models/Entity/UserJournal.cs +++ b/QYZH.InteractiveMagazine.Models/Entity/UserJournal.cs @@ -31,12 +31,18 @@ namespace QYZH.InteractiveMagazine.Models.Entity public long JournalId { get; set; } /// - /// Desc:关联类型: Read(已读), Favorite(收藏), Subscribe(订阅) - /// Default:Read + /// Desc:关联类型: 默认0 /// Nullable:False /// [SugarColumn(ColumnName = "Type")] - public UserJournalTypeEnum Type { get; set; } + public int Type { get; set; } = 0; + /// + /// Desc:二维码地址 + /// Default: + /// Nullable:True + /// + [SugarColumn(ColumnName = "QrCodeUrl")] + public string? QrCodeUrl { get; set; } } } diff --git a/QYZH.InteractiveMagazine.Models/Enum/UserJournalTypeEnum.cs b/QYZH.InteractiveMagazine.Models/Enum/UserJournalTypeEnum.cs deleted file mode 100644 index 2004c9d..0000000 --- a/QYZH.InteractiveMagazine.Models/Enum/UserJournalTypeEnum.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel; - -namespace QYZH.InteractiveMagazine.Models.Enum; - -/// -/// 用户期刊关联类型枚举 -/// -public enum UserJournalTypeEnum -{ - /// - /// 已读 - /// - [Description("已读")] - Read = 1, - /// - /// 收藏 - /// - [Description("收藏")] - Favorite = 2, - /// - /// 订阅 - /// - [Description("订阅")] - Subscribe = 3 -} diff --git a/QYZH.InteractiveMagazine.Service/UserJournalService.cs b/QYZH.InteractiveMagazine.Service/UserJournalService.cs index a03af33..d3d7847 100644 --- a/QYZH.InteractiveMagazine.Service/UserJournalService.cs +++ b/QYZH.InteractiveMagazine.Service/UserJournalService.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.Logging; +using QYZH.InteractiveMagazine.Common.Helpers; +using QYZH.InteractiveMagazine.Infrastructure.OSS; using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ; using QYZH.InteractiveMagazine.IService; using QYZH.InteractiveMagazine.Models.Common; @@ -20,6 +22,7 @@ public class UserJournalService( BaseRepository journalRepository, ILogger logger, IRabbitMQService rabbitMqService, + OssService ossService, IPetService petService) : BaseRepository, IUserJournalService { @@ -32,8 +35,6 @@ public class UserJournalService( /// public async Task BindJournalAsync(long userId, BindJournalInput input) { - logger.LogInformation("用户绑定期刊,UserId: {UserId}, JournalId: {JournalId}, Id: {Id}, Type: {Type}", - userId, input.JournalId, input.Id, input.Type); // 校验参数 if (input.JournalId <= 0|| input.Id <= 0) @@ -63,17 +64,10 @@ public class UserJournalService( throw new BusinessException("该期刊暂未发布,无法绑定", ResultCode.UNPROCESSABLE_ENTITY); } - // 防重复绑定:同一用户 + 期刊 + 实例 + 类型 - if (!Enum.TryParse(input.Type, true, out var bindType)) - { - throw new BusinessException("关联类型不正确", ResultCode.BAD_REQUEST); - } - var userJournal = await userJournalRepository.Queryable() .Where(uj => uj.Id == input.Id && uj.JournalId == input.JournalId && !uj.IsDeleted) .FirstAsync(); - // 检查是否为首次绑定期刊(用于激活宠物) if (userJournal == null) { logger.LogWarning("绑定期刊失败,二维码记录不存在,UserId: {UserId}, JournalId: {JournalId}, Id: {Id}", userId, input.JournalId, input.Id); @@ -97,7 +91,6 @@ public class UserJournalService( // 创建绑定记录 var updateCount = await userJournalRepository.Updateable() .SetColumns(uj => uj.UserId == userId) - .SetColumns(uj => uj.Type == bindType) .SetColumns(uj => uj.UpdatedBy == userId.ToString()) .SetColumns(uj => uj.UpdatedAt == DateTime.Now) .Where(uj => uj.Id == input.Id && !uj.IsDeleted && (uj.UserId == null || uj.UserId == 0)) @@ -111,7 +104,6 @@ public class UserJournalService( logger.LogInformation("用户绑定期刊成功,UserId: {UserId}, JournalId: {JournalId}, Id: {Id}", userId, input.JournalId, userJournal.Id); userJournal.UserId = userId; - userJournal.Type = bindType; userJournal.UpdatedBy = userId.ToString(); userJournal.UpdatedAt = DateTime.Now; @@ -152,11 +144,6 @@ public class UserJournalService( throw new BusinessException("期刊Id不能为空", ResultCode.BAD_REQUEST); } - if (!Enum.TryParse(input.Type, true, out var type)) - { - throw new BusinessException("关联类型不正确", ResultCode.BAD_REQUEST); - } - var journal = await journalRepository.GetByIdAsync(input.JournalId); if (journal == null || journal.IsDeleted) { @@ -172,7 +159,7 @@ public class UserJournalService( { UserId = null, JournalId = input.JournalId, - Type = type, + Type = 0, Status = (int)UserJournalStatusEnum.Active, IsDeleted = false, CreatedBy = operatorId.ToString(), @@ -181,6 +168,17 @@ public class UserJournalService( UpdatedAt = DateTime.Now }; + var qrCodeContent = BuildQrCodeContent(record.JournalId, record.Id); + var qrCodeKey = $"journal/qrcode/{record.JournalId}/{record.Id}.png"; + using var qrCodeStream = new MemoryStream(QrCodeHelper.GeneratePng(qrCodeContent)); + var uploadedKey = ossService.PutObject(qrCodeKey, qrCodeStream); + if (string.IsNullOrWhiteSpace(uploadedKey)) + { + throw new BusinessException("二维码图片上传失败,请稍后重试", ResultCode.GLOBAL_ERROR); + } + + record.QrCodeUrl = uploadedKey; + var result = await userJournalRepository.InsertAsync(record); if (!result) { @@ -322,7 +320,7 @@ public class UserJournalService( Type = record.Type.ToString(), Status = record.Status.ToString(), IsBound = record.UserId.HasValue && record.UserId.Value > 0, - QrCodeContent = BuildQrCodeContent(record.JournalId, record.Id), + QrCodeUrl = DomainHelper.OssFullUrl(record.QrCodeUrl ?? string.Empty), CreatedAt = record.CreatedAt, BoundAt = record.UserId.HasValue && record.UserId.Value > 0 ? record.UpdatedAt : null }; diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs index e80782f..62e2211 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs +++ b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs @@ -239,7 +239,8 @@ public class UserAnswerTaskController : WeChatBaseController { try { - var userId = ConstUserId;//GetCurrentUserId(); + + var userId = GetCurrentUserId(); if (userId == 0) { return BaseResponse.Fail(ResultCode.DENY, "未获取到用户信息");