From ae2c6ddfc78699f8e025b91e3771ad06a9178338 Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Thu, 4 Jun 2026 18:03:34 +0800 Subject: [PATCH] feat: add wechat mini program mall, check-in supplement and pet skin system This commit implements a complete WeChat mini program mall and related features: 1. Add pet skin entity and backpack item type support 2. Add make-up check-in and missed date query functions 3. Implement mall product browsing, exchange and record query 4. Add backpack management and item usage (make-up card) 5. Add pet skin equipping function 6. Fix UserBag ItemId type from int to long 7. Adjust Dockerfile exposed port to 8080 8. Fix session cookie unprotect warning --- .../ICheckInService.cs | 16 + .../IWxMallService.cs | 60 +++ .../Dto/Bag/UserBagDto.cs | 87 ++++ .../Dto/Mall/WxMallDto.cs | 80 +++ .../Entity/ExchangeRecord.cs | 69 +++ QYZH.InteractiveMagazine.Models/Entity/Pet.cs | 7 + .../Entity/PetSkin.cs | 55 ++ .../Entity/UserBag.cs | 2 +- .../CheckInService.cs | 159 +++++- .../WxMallService.cs | 470 ++++++++++++++++++ .../Controllers/WeChat/BagController.cs | 52 ++ .../Controllers/WeChat/MallController.cs | 70 +++ QYZH.InteractiveMagazine.WebApi/Dockerfile | 4 +- QYZH.InteractiveMagazine.WebApi/Program.cs | 5 + 14 files changed, 1128 insertions(+), 8 deletions(-) create mode 100644 QYZH.InteractiveMagazine.IService/IWxMallService.cs create mode 100644 QYZH.InteractiveMagazine.Models/Dto/Bag/UserBagDto.cs create mode 100644 QYZH.InteractiveMagazine.Models/Dto/Mall/WxMallDto.cs create mode 100644 QYZH.InteractiveMagazine.Models/Entity/ExchangeRecord.cs create mode 100644 QYZH.InteractiveMagazine.Models/Entity/PetSkin.cs create mode 100644 QYZH.InteractiveMagazine.Service/WxMallService.cs create mode 100644 QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/BagController.cs create mode 100644 QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/MallController.cs diff --git a/QYZH.InteractiveMagazine.IService/ICheckInService.cs b/QYZH.InteractiveMagazine.IService/ICheckInService.cs index 903e632..b9b264c 100644 --- a/QYZH.InteractiveMagazine.IService/ICheckInService.cs +++ b/QYZH.InteractiveMagazine.IService/ICheckInService.cs @@ -21,4 +21,20 @@ public interface ICheckInService : IBaseService /// 用户Id /// 签到信息 Task GetCheckInInfoAsync(long userId); + + /// + /// 补签(消耗补签卡,补签历史漏签日期) + /// + /// 用户Id + /// 补签目标日期 + /// 补签结果 + Task MakeUpCheckInAsync(long userId, DateTime targetDate); + + /// + /// 获取用户可补签的日期列表(历史漏签日期) + /// + /// 用户Id + /// 往前查看天数(默认30天) + /// 漏签日期列表 + Task> GetMissedDatesAsync(long userId, int days = 30); } diff --git a/QYZH.InteractiveMagazine.IService/IWxMallService.cs b/QYZH.InteractiveMagazine.IService/IWxMallService.cs new file mode 100644 index 0000000..f3a7838 --- /dev/null +++ b/QYZH.InteractiveMagazine.IService/IWxMallService.cs @@ -0,0 +1,60 @@ +using QYZH.InteractiveMagazine.Models.Dto.Bag; +using QYZH.InteractiveMagazine.Models.Dto.Mall; +using QYZH.InteractiveMagazine.Models.Entity; + +namespace QYZH.InteractiveMagazine.IService; + +/// +/// 小程序商城服务接口 +/// +public interface IWxMallService : IBaseService +{ + /// + /// 获取商城商品列表(仅上架 + 在售商品) + /// + /// 当前用户Id(用于判断是否已拥有) + /// 按商品类型筛选(可选) + Task> GetProductsAsync(long userId, string? type = null); + + /// + /// 获取商品详情 + /// + /// 当前用户Id + /// 商品Id + Task GetProductDetailAsync(long userId, long productId); + + /// + /// 积分兑换商品 + /// + /// 用户Id + /// 兑换参数 + Task ExchangeAsync(long userId, ExchangeInput input); + + /// + /// 获取用户兑换记录 + /// + /// 用户Id + /// 返回数量 + Task> GetExchangeRecordsAsync(long userId, int limit = 20); + + /// + /// 获取用户背包物品列表 + /// + /// 用户Id + /// 按物品类型筛选(可选) + Task> GetBagItemsAsync(long userId, string? itemType = null); + + /// + /// 使用背包物品(补签卡等消耗品) + /// + /// 用户Id + /// 使用参数 + Task UseItemAsync(long userId, UseItemInput input); + + /// + /// 宠物换肤 + /// + /// 用户Id + /// 换肤参数 + Task EquipSkinAsync(long userId, EquipSkinInput input); +} diff --git a/QYZH.InteractiveMagazine.Models/Dto/Bag/UserBagDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Bag/UserBagDto.cs new file mode 100644 index 0000000..d23cdab --- /dev/null +++ b/QYZH.InteractiveMagazine.Models/Dto/Bag/UserBagDto.cs @@ -0,0 +1,87 @@ +namespace QYZH.InteractiveMagazine.Models.Dto.Bag; + +/// +/// 背包物品输出 +/// +public class UserBagOutput +{ + public long Id { get; set; } + public long ItemId { get; set; } + public string ItemType { get; set; } = string.Empty; + public int Quantity { get; set; } + public string Status { get; set; } = string.Empty; + + /// + /// 关联商品信息(如果有) + /// + public BagProductBrief? Product { get; set; } + + /// + /// 关联皮肤信息(PetBg 类型时) + /// + public BagSkinBrief? Skin { get; set; } + + public DateTime CreatedAt { get; set; } +} + +/// +/// 背包中的商品简要 +/// +public class BagProductBrief +{ + public string Name { get; set; } = string.Empty; + public string? ImageUrl { get; set; } + public string? Description { get; set; } +} + +/// +/// 背包中的皮肤简要 +/// +public class BagSkinBrief +{ + public long SkinId { get; set; } + public string SkinName { get; set; } = string.Empty; + public string? SkinImage { get; set; } + public string Rarity { get; set; } = string.Empty; +} + +/// +/// 使用物品输入(补签卡) +/// +public class UseItemInput +{ + /// + /// 背包物品Id + /// + public long BagItemId { get; set; } + + /// + /// 补签目标日期(yyyy-MM-dd,仅补签卡使用) + /// + public string? TargetDate { get; set; } +} + +/// +/// 换肤输入 +/// +public class EquipSkinInput +{ + /// + /// 皮肤Id(传0则恢复默认皮肤) + /// + public long SkinId { get; set; } +} + +/// +/// 使用物品输出 +/// +public class UseItemOutput +{ + public bool IsSuccess { get; set; } + public string Message { get; set; } = string.Empty; + + /// + /// 补签时返回的签到结果 + /// + public object? Extra { get; set; } +} diff --git a/QYZH.InteractiveMagazine.Models/Dto/Mall/WxMallDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Mall/WxMallDto.cs new file mode 100644 index 0000000..c5cb70f --- /dev/null +++ b/QYZH.InteractiveMagazine.Models/Dto/Mall/WxMallDto.cs @@ -0,0 +1,80 @@ +namespace QYZH.InteractiveMagazine.Models.Dto.Mall; + +/// +/// 小程序商品展示输出 +/// +public class WxProductOutput +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public string? ImageUrl { get; set; } + public int Price { get; set; } + public string Type { get; set; } = string.Empty; + + /// + /// 皮肤信息(仅 PetBg 类型有值) + /// + public PetSkinBrief? Skin { get; set; } + + /// + /// 用户是否已拥有(PetBg 类型时判断背包中是否有) + /// + public bool Owned { get; set; } +} + +/// +/// 皮肤简要信息 +/// +public class PetSkinBrief +{ + public long SkinId { get; set; } + public string SkinName { get; set; } = string.Empty; + public string? SkinImage { get; set; } + public string? Description { get; set; } + public string Rarity { get; set; } = string.Empty; +} + +/// +/// 兑换输入 +/// +public class ExchangeInput +{ + /// + /// 商品Id + /// + public long ProductId { get; set; } + + /// + /// 兑换数量(默认1) + /// + public int Quantity { get; set; } = 1; +} + +/// +/// 兑换输出 +/// +public class ExchangeOutput +{ + public long RecordId { get; set; } + public string ProductName { get; set; } = string.Empty; + public int PointsCost { get; set; } + public int PointsBalance { get; set; } + public string Message { get; set; } = string.Empty; +} + +/// +/// 兑换记录输出 +/// +public class ExchangeRecordOutput +{ + public long Id { get; set; } + public long ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public string ProductType { get; set; } = string.Empty; + public int PointsCost { get; set; } + public int PointsBalance { get; set; } + public int Quantity { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } +} diff --git a/QYZH.InteractiveMagazine.Models/Entity/ExchangeRecord.cs b/QYZH.InteractiveMagazine.Models/Entity/ExchangeRecord.cs new file mode 100644 index 0000000..928008c --- /dev/null +++ b/QYZH.InteractiveMagazine.Models/Entity/ExchangeRecord.cs @@ -0,0 +1,69 @@ +using SqlSugar; + +namespace QYZH.InteractiveMagazine.Models.Entity +{ + /// + /// 商品兑换记录表 + /// + [SugarTable("ExchangeRecord")] + public partial class ExchangeRecord : SqlSugarBaseEntity + { + public ExchangeRecord() { } + + /// + /// Desc:用户Id + /// Default: + /// Nullable:False + /// + public long UserId { get; set; } + + /// + /// Desc:商品Id + /// Default: + /// Nullable:False + /// + public long ProductId { get; set; } + + /// + /// Desc:商品名称(快照) + /// Default: + /// Nullable:False + /// + public string ProductName { get; set; } + + /// + /// Desc:商品类型(快照): MakeUpCard, PetBg + /// Default: + /// Nullable:False + /// + public string ProductType { get; set; } + + /// + /// Desc:消耗积分 + /// Default: + /// Nullable:False + /// + public int PointsCost { get; set; } + + /// + /// Desc:兑换后积分余额 + /// Default: + /// Nullable:False + /// + public int PointsBalance { get; set; } + + /// + /// Desc:兑换数量 + /// Default:1 + /// Nullable:False + /// + public int Quantity { get; set; } + + /// + /// Desc:状态: Success, Refunded + /// Default:Success + /// Nullable:False + /// + public new string Status { get; set; } + } +} diff --git a/QYZH.InteractiveMagazine.Models/Entity/Pet.cs b/QYZH.InteractiveMagazine.Models/Entity/Pet.cs index 9a804de..55a8881 100644 --- a/QYZH.InteractiveMagazine.Models/Entity/Pet.cs +++ b/QYZH.InteractiveMagazine.Models/Entity/Pet.cs @@ -47,6 +47,13 @@ namespace QYZH.InteractiveMagazine.Models.Entity /// public int FeedingCount {get;set;} + /// + /// Desc:当前皮肤Id(0为默认皮肤) + /// Default:0 + /// Nullable:False + /// + public long CurrentSkinId {get;set;} + /// /// Desc:宠物类型 /// Default:Normal diff --git a/QYZH.InteractiveMagazine.Models/Entity/PetSkin.cs b/QYZH.InteractiveMagazine.Models/Entity/PetSkin.cs new file mode 100644 index 0000000..9450908 --- /dev/null +++ b/QYZH.InteractiveMagazine.Models/Entity/PetSkin.cs @@ -0,0 +1,55 @@ +using SqlSugar; + +namespace QYZH.InteractiveMagazine.Models.Entity +{ + /// + /// 宠物皮肤定义表 + /// + [SugarTable("PetSkin")] + public partial class PetSkin : SqlSugarBaseEntity + { + public PetSkin() { } + + /// + /// Desc:皮肤名称 + /// Default: + /// Nullable:False + /// + public string Name { get; set; } + + /// + /// Desc:皮肤图片 + /// Default: + /// Nullable:True + /// + public string ImageUrl { get; set; } + + /// + /// Desc:描述/特效说明 + /// Default: + /// Nullable:True + /// + public string Description { get; set; } + + /// + /// Desc:关联商品Id(用于兑换) + /// Default: + /// Nullable:False + /// + public long ProductId { get; set; } + + /// + /// Desc:稀有度: Normal, Rare, Epic, Legendary + /// Default:Normal + /// Nullable:False + /// + public string Rarity { get; set; } + + /// + /// Desc:排序权重 + /// Default:0 + /// Nullable:False + /// + public int SortOrder { get; set; } + } +} diff --git a/QYZH.InteractiveMagazine.Models/Entity/UserBag.cs b/QYZH.InteractiveMagazine.Models/Entity/UserBag.cs index a0f72c9..b1102b6 100644 --- a/QYZH.InteractiveMagazine.Models/Entity/UserBag.cs +++ b/QYZH.InteractiveMagazine.Models/Entity/UserBag.cs @@ -31,7 +31,7 @@ namespace QYZH.InteractiveMagazine.Models.Entity /// Default: /// Nullable:False /// - public int ItemId {get;set;} + public long ItemId {get;set;} /// /// Desc:数量 diff --git a/QYZH.InteractiveMagazine.Service/CheckInService.cs b/QYZH.InteractiveMagazine.Service/CheckInService.cs index 5ce4413..df0913c 100644 --- a/QYZH.InteractiveMagazine.Service/CheckInService.cs +++ b/QYZH.InteractiveMagazine.Service/CheckInService.cs @@ -192,11 +192,6 @@ public class CheckInService( if (lastDate == today || lastDate == today.AddDays(-1)) { consecutiveDays = lastRecord.ConsecutiveDays; - if (lastDate == today) - { - // 今天已签到,连续天数就是今天的值 - } - // 如果是昨天,则连续天数保持(今天还没签到) } } @@ -233,6 +228,160 @@ public class CheckInService( }; } + /// + /// 补签(消耗补签卡,补签历史漏签日期) + /// + public async Task MakeUpCheckInAsync(long userId, DateTime targetDate) + { + logger.LogInformation("用户补签,UserId: {UserId}, TargetDate: {Date}", userId, targetDate); + + targetDate = targetDate.Date; + + if (targetDate >= DateTime.Now.Date) + throw new BusinessException("只能补签过去的日期", 400); + + // 检查目标日期是否已有签到记录 + var alreadyCheckedIn = await checkInRecordRepository.Context.Queryable() + .Where(r => r.UserId == userId && !r.IsDeleted + && r.CheckInDate >= targetDate && r.CheckInDate < targetDate.AddDays(1)) + .AnyAsync(); + + if (alreadyCheckedIn) + throw new BusinessException($"{targetDate:yyyy-MM-dd} 已签到,无需补签", 400); + + // 查询用户信息 + var user = await checkInRecordRepository.Context.Queryable() + .Where(u => u.Id == userId && !u.IsDeleted) + .FirstAsync(); + + if (user == null) + throw new BusinessException("用户不存在", 404); + + // 查询宠物 + var pet = await checkInRecordRepository.Context.Queryable() + .Where(p => p.UserId == userId && !p.IsDeleted) + .FirstAsync(); + + // 补签奖励按基础值计算(不享受连续签到加成) + var (pointsReward, growthReward) = await CalculateRewardsAsync(1); + + var result = new CheckInOutput(); + + await checkInRecordRepository.UseTranAsync(async () => + { + // 创建补签记录 + var checkInRecord = new CheckInRecord + { + UserId = userId, + CheckInDate = targetDate, + PointsAwarded = pointsReward, + GrowthPointsAwarded = growthReward, + ConsecutiveDays = 0, // 补签不纳入连续天数 + Type = "MakeUp", + Status = "Success", + IsDeleted = false, + CreatedBy = userId.ToString(), + CreatedAt = DateTime.Now, + UpdatedBy = userId.ToString(), + UpdatedAt = DateTime.Now + }; + var recordId = await checkInRecordRepository.Insertable(checkInRecord).ExecuteReturnIdentityAsync(); + checkInRecord.Id = recordId; + + // 更新用户积分和成长值 + var newPointsBalance = user.Points + pointsReward; + var newGrowthBalance = user.GrowthPoints + growthReward; + + await checkInRecordRepository.Context.Updateable() + .SetColumns(u => u.Points == newPointsBalance) + .SetColumns(u => u.GrowthPoints == newGrowthBalance) + .Where(u => u.Id == userId && !u.IsDeleted) + .ExecuteCommandAsync(); + + // 创建积分变动记录 + var pointsRecord = new PointsRecord + { + UserId = userId, + ChangeAmount = pointsReward, + BalanceAfter = newPointsBalance, + ChangeType = "MakeUpSign", + RelatedId = recordId, + Description = $"补签奖励({targetDate:yyyy-MM-dd})", + Type = "Income", + Status = "Success", + IsDeleted = false, + CreatedBy = userId.ToString(), + CreatedAt = DateTime.Now, + UpdatedBy = userId.ToString(), + UpdatedAt = DateTime.Now + }; + await checkInRecordRepository.Context.Insertable(pointsRecord).ExecuteCommandAsync(); + + result.RecordId = (long)recordId; + result.CheckInDate = targetDate; + result.ConsecutiveDays = 0; + result.PointsAwarded = pointsReward; + result.GrowthPointsAwarded = growthReward; + result.PointsBalance = newPointsBalance; + result.GrowthPointsBalance = newGrowthBalance; + result.HasPet = pet != null; + }); + + // 如果有活跃宠物,喂养成长值 + if (pet != null && pet.Status == "Active" && growthReward > 0) + { + try + { + var feedResult = await petService.FeedPetAsync(userId, new Models.Dto.Pet.FeedPetInput + { + PetId = pet.Id, + GrowthPoints = growthReward + }); + + result.HasEvolved = feedResult.HasEvolved; + result.EvolvedStageName = feedResult.EvolvedStageName; + } + catch (Exception ex) + { + logger.LogWarning(ex, "补签后喂养宠物失败,PetId: {PetId}", pet.Id); + // 补偿机制:如需可在此创建补偿任务 + } + } + + logger.LogInformation("补签成功,UserId: {UserId}, Date: {Date}, 积分+{Points}, 成长值+{Growth}", + userId, targetDate, pointsReward, growthReward); + + return result; + } + + /// + /// 获取用户漏签日期列表 + /// + public async Task> GetMissedDatesAsync(long userId, int days = 30) + { + var startDate = DateTime.Now.Date.AddDays(-days); + + // 查询该时间段内所有签到记录 + var checkedDates = await checkInRecordRepository.Context.Queryable() + .Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate >= startDate) + .Select(r => r.CheckInDate.Date) + .ToListAsync(); + + var checkedDateSet = new HashSet(checkedDates); + var missedDates = new List(); + + // 遍历每一天,找出漏签的日期(排除今天,今天不算漏签) + for (var date = startDate; date < DateTime.Now.Date; date = date.AddDays(1)) + { + if (!checkedDateSet.Contains(date)) + { + missedDates.Add(date); + } + } + + return missedDates; + } + /// /// 计算连续签到天数 /// diff --git a/QYZH.InteractiveMagazine.Service/WxMallService.cs b/QYZH.InteractiveMagazine.Service/WxMallService.cs new file mode 100644 index 0000000..8ace22b --- /dev/null +++ b/QYZH.InteractiveMagazine.Service/WxMallService.cs @@ -0,0 +1,470 @@ +using Microsoft.Extensions.Logging; +using QYZH.InteractiveMagazine.IService; +using QYZH.InteractiveMagazine.Models.Common; +using QYZH.InteractiveMagazine.Models.Dto.Bag; +using QYZH.InteractiveMagazine.Models.Dto.Mall; +using QYZH.InteractiveMagazine.Models.Entity; +using QYZH.InteractiveMagazine.Repository; + +namespace QYZH.InteractiveMagazine.Service; + +/// +/// 小程序商城服务实现 +/// +public class WxMallService( + BaseRepository exchangeRecordRepository, + ICheckInService checkInService, + ILogger logger) + : BaseRepository, IWxMallService +{ + /// + /// 获取商城商品列表(仅上架 + 在售) + /// + public async Task> GetProductsAsync(long userId, string? type = null) + { + var query = exchangeRecordRepository.Context.Queryable() + .Where(p => !p.IsDeleted && p.IsActive && p.SaleStatus == "OnSale") + .WhereIF(!string.IsNullOrEmpty(type), p => p.Type == type) + .OrderByDescending(p => p.CreatedAt); + + var products = await query.ToListAsync(); + + // 批量查询皮肤信息 + var skinProductIds = products.Where(p => p.Type == "PetBg").Select(p => p.Id).ToList(); + var skins = skinProductIds.Count > 0 + ? await exchangeRecordRepository.Context.Queryable() + .Where(s => skinProductIds.Contains(s.ProductId) && !s.IsDeleted) + .ToListAsync() + : new List(); + + // 批量查询用户背包(判断已拥有) + var bagItems = skinProductIds.Count > 0 + ? await exchangeRecordRepository.Context.Queryable() + .Where(b => b.UserId == userId && !b.IsDeleted && b.Status == "Available" + && skinProductIds.Contains(b.ItemId)) + .ToListAsync() + : new List(); + + return products.Select(p => + { + var skin = skins.FirstOrDefault(s => s.ProductId == p.Id); + var owned = bagItems.Any(b => b.ItemId == p.Id); + + return new WxProductOutput + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + ImageUrl = p.ImageUrl, + Price = p.Price, + Type = p.Type, + Owned = owned, + Skin = skin != null ? new PetSkinBrief + { + SkinId = skin.Id, + SkinName = skin.Name, + SkinImage = skin.ImageUrl, + Description = skin.Description, + Rarity = skin.Rarity + } : null + }; + }).ToList(); + } + + /// + /// 获取商品详情 + /// + public async Task GetProductDetailAsync(long userId, long productId) + { + var product = await exchangeRecordRepository.Context.Queryable() + .Where(p => p.Id == productId && !p.IsDeleted && p.IsActive && p.SaleStatus == "OnSale") + .FirstAsync(); + + if (product == null) return null; + + PetSkinBrief? skinBrief = null; + if (product.Type == "PetBg") + { + var skin = await exchangeRecordRepository.Context.Queryable() + .Where(s => s.ProductId == product.Id && !s.IsDeleted) + .FirstAsync(); + + if (skin != null) + { + skinBrief = new PetSkinBrief + { + SkinId = skin.Id, + SkinName = skin.Name, + SkinImage = skin.ImageUrl, + Description = skin.Description, + Rarity = skin.Rarity + }; + } + } + + var owned = await exchangeRecordRepository.Context.Queryable() + .Where(b => b.UserId == userId && b.ItemId == product.Id && !b.IsDeleted && b.Status == "Available") + .AnyAsync(); + + return new WxProductOutput + { + Id = product.Id, + Name = product.Name, + Description = product.Description, + ImageUrl = product.ImageUrl, + Price = product.Price, + Type = product.Type, + Owned = owned, + Skin = skinBrief + }; + } + + /// + /// 积分兑换商品 + /// + public async Task ExchangeAsync(long userId, ExchangeInput input) + { + logger.LogInformation("用户兑换商品,UserId: {UserId}, ProductId: {ProductId}, Qty: {Qty}", + userId, input.ProductId, input.Quantity); + + if (input.ProductId <= 0) + throw new BusinessException("商品Id无效", 400); + + if (input.Quantity <= 0) + throw new BusinessException("兑换数量必须大于0", 400); + + // 查询商品 + var product = await exchangeRecordRepository.Context.Queryable() + .Where(p => p.Id == input.ProductId && !p.IsDeleted && p.IsActive && p.SaleStatus == "OnSale") + .FirstAsync(); + + if (product == null) + throw new BusinessException("商品不存在或已下架", 404); + + var totalCost = product.Price * input.Quantity; + + // 查询用户积分 + var user = await exchangeRecordRepository.Context.Queryable() + .Where(u => u.Id == userId && !u.IsDeleted) + .FirstAsync(); + + if (user == null) + throw new BusinessException("用户不存在", 404); + + if (user.Points < totalCost) + throw new BusinessException($"积分不足,需要 {totalCost} 积分,当前余额 {user.Points}", 400); + + var newPointsBalance = user.Points - totalCost; + long recordId = 0; + + await exchangeRecordRepository.UseTranAsync(async () => + { + // 扣除用户积分 + await exchangeRecordRepository.Context.Updateable() + .SetColumns(u => u.Points == newPointsBalance) + .SetColumns(u => u.UpdatedAt == DateTime.Now) + .Where(u => u.Id == userId && !u.IsDeleted) + .ExecuteCommandAsync(); + + // 创建兑换记录 + var record = new ExchangeRecord + { + UserId = userId, + ProductId = product.Id, + ProductName = product.Name, + ProductType = product.Type, + PointsCost = totalCost, + PointsBalance = newPointsBalance, + Quantity = input.Quantity, + Status = "Success", + IsDeleted = false, + CreatedBy = userId.ToString(), + CreatedAt = DateTime.Now, + UpdatedBy = userId.ToString(), + UpdatedAt = DateTime.Now + }; + var inserted = await exchangeRecordRepository.InsertReturnEntityAsync(record); + recordId = inserted.Id; + + // 加入背包 + var existingBag = await exchangeRecordRepository.Context.Queryable() + .Where(b => b.UserId == userId && b.ItemId == product.Id && !b.IsDeleted && b.Status == "Available") + .FirstAsync(); + + if (existingBag != null) + { + // 已有同类物品,累加数量 + await exchangeRecordRepository.Context.Updateable() + .SetColumns(b => b.Quantity == existingBag.Quantity + input.Quantity) + .SetColumns(b => b.UpdatedAt == DateTime.Now) + .Where(b => b.Id == existingBag.Id) + .ExecuteCommandAsync(); + } + else + { + // 新建背包物品 + var bagItem = new UserBag + { + UserId = userId, + ItemId = product.Id, + ItemType = product.Type, + Quantity = input.Quantity, + MetaData = product.MetaData, + Type = product.Type, + Status = "Available", + IsDeleted = false, + CreatedBy = userId.ToString(), + CreatedAt = DateTime.Now, + UpdatedBy = userId.ToString(), + UpdatedAt = DateTime.Now + }; + await exchangeRecordRepository.Context.Insertable(bagItem).ExecuteCommandAsync(); + } + + // 创建积分消耗记录 + var pointsRecord = new PointsRecord + { + UserId = userId, + ChangeAmount = -totalCost, + BalanceAfter = newPointsBalance, + ChangeType = "Exchange", + RelatedId = recordId, + Description = $"兑换 {product.Name} x{input.Quantity}", + Type = "Expense", + Status = "Success", + IsDeleted = false, + CreatedBy = userId.ToString(), + CreatedAt = DateTime.Now, + UpdatedBy = userId.ToString(), + UpdatedAt = DateTime.Now + }; + await exchangeRecordRepository.Context.Insertable(pointsRecord).ExecuteCommandAsync(); + }); + + logger.LogInformation("兑换成功,UserId: {UserId}, Product: {Product}, Cost: {Cost}", + userId, product.Name, totalCost); + + return new ExchangeOutput + { + RecordId = recordId, + ProductName = product.Name, + PointsCost = totalCost, + PointsBalance = newPointsBalance, + Message = $"兑换成功!{product.Name} x{input.Quantity} 已放入背包" + }; + } + + /// + /// 获取用户兑换记录 + /// + public async Task> GetExchangeRecordsAsync(long userId, int limit = 20) + { + return await exchangeRecordRepository.Queryable() + .Where(r => r.UserId == userId && !r.IsDeleted) + .OrderByDescending(r => r.CreatedAt) + .Take(limit) + .Select(r => new ExchangeRecordOutput + { + Id = r.Id, + ProductId = r.ProductId, + ProductName = r.ProductName, + ProductType = r.ProductType, + PointsCost = r.PointsCost, + PointsBalance = r.PointsBalance, + Quantity = r.Quantity, + Status = r.Status, + CreatedAt = r.CreatedAt + }) + .ToListAsync(); + } + + /// + /// 获取用户背包物品 + /// + public async Task> GetBagItemsAsync(long userId, string? itemType = null) + { + var query = exchangeRecordRepository.Context.Queryable() + .Where(b => b.UserId == userId && !b.IsDeleted && b.Status == "Available") + .WhereIF(!string.IsNullOrEmpty(itemType), b => b.ItemType == itemType) + .OrderByDescending(b => b.CreatedAt); + + var bagItems = await query.ToListAsync(); + + if (bagItems.Count == 0) return []; + + // 批量查询关联商品 + var itemIds = bagItems.Select(b => b.ItemId).Distinct().ToList(); + var products = await exchangeRecordRepository.Context.Queryable() + .Where(p => itemIds.Contains(p.Id) && !p.IsDeleted) + .ToListAsync(); + + // 批量查询关联皮肤(PetBg 类型) + var skinProductIds = bagItems.Where(b => b.ItemType == "PetBg").Select(b => b.ItemId).Distinct().ToList(); + var skins = skinProductIds.Count > 0 + ? await exchangeRecordRepository.Context.Queryable() + .Where(s => skinProductIds.Contains(s.ProductId) && !s.IsDeleted) + .ToListAsync() + : new List(); + + return bagItems.Select(b => + { + var product = products.FirstOrDefault(p => p.Id == b.ItemId); + var skin = skins.FirstOrDefault(s => s.ProductId == b.ItemId); + + return new UserBagOutput + { + Id = b.Id, + ItemId = b.ItemId, + ItemType = b.ItemType, + Quantity = b.Quantity, + Status = b.Status, + CreatedAt = b.CreatedAt, + Product = product != null ? new BagProductBrief + { + Name = product.Name, + ImageUrl = product.ImageUrl, + Description = product.Description + } : null, + Skin = skin != null ? new BagSkinBrief + { + SkinId = skin.Id, + SkinName = skin.Name, + SkinImage = skin.ImageUrl, + Rarity = skin.Rarity + } : null + }; + }).ToList(); + } + + /// + /// 使用背包物品(目前仅支持补签卡) + /// + public async Task UseItemAsync(long userId, UseItemInput input) + { + logger.LogInformation("使用背包物品,UserId: {UserId}, BagItemId: {BagItemId}", userId, input.BagItemId); + + if (input.BagItemId <= 0) + throw new BusinessException("背包物品Id无效", 400); + + var bagItem = await exchangeRecordRepository.Context.Queryable() + .Where(b => b.Id == input.BagItemId && b.UserId == userId && !b.IsDeleted && b.Status == "Available") + .FirstAsync(); + + if (bagItem == null) + throw new BusinessException("背包物品不存在", 404); + + if (bagItem.Quantity <= 0) + throw new BusinessException("物品数量不足", 400); + + switch (bagItem.ItemType) + { + case "MakeUpCard": + return await UseMakeUpCardAsync(userId, bagItem, input); + default: + throw new BusinessException($"不支持使用该类型物品: {bagItem.ItemType}", 400); + } + } + + /// + /// 使用补签卡 + /// + private async Task UseMakeUpCardAsync(long userId, UserBag bagItem, UseItemInput input) + { + if (string.IsNullOrEmpty(input.TargetDate)) + throw new BusinessException("请指定补签日期", 400); + + if (!DateTime.TryParse(input.TargetDate, out var targetDate)) + throw new BusinessException("日期格式无效", 400); + + targetDate = targetDate.Date; + + if (targetDate >= DateTime.Now.Date) + throw new BusinessException("只能补签过去的日期", 400); + + // 调用签到服务执行补签 + var checkInResult = await checkInService.MakeUpCheckInAsync(userId, targetDate); + + // 扣减补签卡数量 + if (bagItem.Quantity <= 1) + { + await exchangeRecordRepository.Context.Updateable() + .SetColumns(b => b.Status == "UsedUp") + .SetColumns(b => b.Quantity == 0) + .SetColumns(b => b.UpdatedAt == DateTime.Now) + .Where(b => b.Id == bagItem.Id) + .ExecuteCommandAsync(); + } + else + { + await exchangeRecordRepository.Context.Updateable() + .SetColumns(b => b.Quantity == bagItem.Quantity - 1) + .SetColumns(b => b.UpdatedAt == DateTime.Now) + .Where(b => b.Id == bagItem.Id) + .ExecuteCommandAsync(); + } + + logger.LogInformation("补签卡使用成功,UserId: {UserId}, TargetDate: {Date}", userId, targetDate); + + return new UseItemOutput + { + IsSuccess = true, + Message = $"补签 {targetDate:yyyy-MM-dd} 成功", + Extra = checkInResult + }; + } + + /// + /// 宠物换肤 + /// + public async Task EquipSkinAsync(long userId, EquipSkinInput input) + { + logger.LogInformation("宠物换肤,UserId: {UserId}, SkinId: {SkinId}", userId, input.SkinId); + + // 查询用户宠物 + var pet = await exchangeRecordRepository.Context.Queryable() + .Where(p => p.UserId == userId && !p.IsDeleted) + .FirstAsync(); + + if (pet == null) + throw new BusinessException("您还没有宠物", 404); + + if (input.SkinId == 0) + { + // 恢复默认皮肤 + await exchangeRecordRepository.Context.Updateable() + .SetColumns(p => p.CurrentSkinId == 0) + .SetColumns(p => p.UpdatedAt == DateTime.Now) + .Where(p => p.Id == pet.Id) + .ExecuteCommandAsync(); + + logger.LogInformation("宠物恢复默认皮肤,UserId: {UserId}", userId); + return; + } + + // 查询皮肤 + var skin = await exchangeRecordRepository.Context.Queryable() + .Where(s => s.Id == input.SkinId && !s.IsDeleted) + .FirstAsync(); + + if (skin == null) + throw new BusinessException("皮肤不存在", 404); + + // 校验背包中是否拥有该皮肤(通过关联商品Id判断) + var hasSkin = await exchangeRecordRepository.Context.Queryable() + .Where(b => b.UserId == userId && b.ItemId == skin.ProductId + && !b.IsDeleted && b.Status == "Available" && b.Quantity > 0) + .AnyAsync(); + + if (!hasSkin) + throw new BusinessException("您尚未拥有该皮肤,请先兑换", 400); + + // 换肤 + await exchangeRecordRepository.Context.Updateable() + .SetColumns(p => p.CurrentSkinId == skin.Id) + .SetColumns(p => p.UpdatedAt == DateTime.Now) + .Where(p => p.Id == pet.Id) + .ExecuteCommandAsync(); + + logger.LogInformation("宠物换肤成功,UserId: {UserId}, Skin: {Skin}", userId, skin.Name); + } +} diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/BagController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/BagController.cs new file mode 100644 index 0000000..cdce17d --- /dev/null +++ b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/BagController.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Mvc; +using QYZH.InteractiveMagazine.IService; +using QYZH.InteractiveMagazine.Models.Dto; +using QYZH.InteractiveMagazine.Models.Dto.Bag; + +namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat; + +/// +/// 小程序背包控制器 +/// +public class BagController(IWxMallService mallService, ILogger logger) : WeChatBaseController +{ + /// + /// 获取背包物品列表 + /// + /// 物品类型筛选(可选): MakeUpCard, PetBg + [HttpGet("items")] + public async Task>> GetBagItems([FromQuery] string? itemType = null) + { + var userId = GetCurrentUserId(); + if (userId == null) return Fail("未获取到用户信息") as dynamic; + + var items = await mallService.GetBagItemsAsync(userId.Value, itemType); + return Success(items); + } + + /// + /// 使用背包物品(补签卡等消耗品) + /// + [HttpPost("useItem")] + public async Task> UseItem([FromBody] UseItemInput input) + { + var userId = GetCurrentUserId(); + if (userId == null) return Fail("未获取到用户信息") as dynamic; + + var result = await mallService.UseItemAsync(userId.Value, input); + return Success(result); + } + + /// + /// 宠物换肤 + /// + [HttpPost("equipSkin")] + public async Task> EquipSkin([FromBody] EquipSkinInput input) + { + var userId = GetCurrentUserId(); + if (userId == null) return Fail("未获取到用户信息") as dynamic; + + await mallService.EquipSkinAsync(userId.Value, input); + return Success(null!, input.SkinId == 0 ? "已恢复默认皮肤" : "换肤成功"); + } +} diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/MallController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/MallController.cs new file mode 100644 index 0000000..86aa633 --- /dev/null +++ b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/MallController.cs @@ -0,0 +1,70 @@ +using Microsoft.AspNetCore.Mvc; +using QYZH.InteractiveMagazine.IService; +using QYZH.InteractiveMagazine.Models.Dto; +using QYZH.InteractiveMagazine.Models.Dto.Mall; + +namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat; + +/// +/// 小程序商城控制器 +/// +public class MallController(IWxMallService mallService, ILogger logger) : WeChatBaseController +{ + /// + /// 获取商城商品列表 + /// + /// 商品类型筛选(可选): MakeUpCard, PetBg + [HttpGet("products")] + public async Task>> GetProducts([FromQuery] string? type = null) + { + var userId = GetCurrentUserId(); + if (userId == null) return Fail("未获取到用户信息") as dynamic; + + var products = await mallService.GetProductsAsync(userId.Value, type); + return Success(products); + } + + /// + /// 获取商品详情 + /// + /// 商品Id + [HttpGet("product/{id}")] + public async Task> GetProductDetail(long id) + { + var userId = GetCurrentUserId(); + if (userId == null) return Fail("未获取到用户信息") as dynamic; + + var product = await mallService.GetProductDetailAsync(userId.Value, id); + if (product == null) + return BaseResponse.Fail(ResultCode.DENY, "商品不存在或已下架"); + + return Success(product); + } + + /// + /// 积分兑换商品 + /// + [HttpPost("exchange")] + public async Task> Exchange([FromBody] ExchangeInput input) + { + var userId = GetCurrentUserId(); + if (userId == null) return Fail("未获取到用户信息") as dynamic; + + var result = await mallService.ExchangeAsync(userId.Value, input); + return Success(result); + } + + /// + /// 获取我的兑换记录 + /// + /// 返回数量(默认20) + [HttpGet("exchangeRecords")] + public async Task>> GetExchangeRecords([FromQuery] int limit = 20) + { + var userId = GetCurrentUserId(); + if (userId == null) return Fail("未获取到用户信息") as dynamic; + + var records = await mallService.GetExchangeRecordsAsync(userId.Value, limit); + return Success(records); + } +} diff --git a/QYZH.InteractiveMagazine.WebApi/Dockerfile b/QYZH.InteractiveMagazine.WebApi/Dockerfile index 243e01e..002772f 100644 --- a/QYZH.InteractiveMagazine.WebApi/Dockerfile +++ b/QYZH.InteractiveMagazine.WebApi/Dockerfile @@ -11,9 +11,9 @@ COPY . . ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -# 暴露端口(根据您的 WebApi 实际监听的端口,通常为 80 或 8080) + # 注意:这里只是声明,实际映射需要在运行容器时指定 -EXPOSE 8090 +EXPOSE 8080 # 启动应用 ENTRYPOINT ["dotnet", "QYZH.InteractiveMagazine.WebApi.dll"] \ No newline at end of file diff --git a/QYZH.InteractiveMagazine.WebApi/Program.cs b/QYZH.InteractiveMagazine.WebApi/Program.cs index 93ea8b9..b0db13c 100644 --- a/QYZH.InteractiveMagazine.WebApi/Program.cs +++ b/QYZH.InteractiveMagazine.WebApi/Program.cs @@ -1,6 +1,7 @@ using Autofac; using Autofac.Extensions.DependencyInjection; using BCrypt.Net; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.OpenApi; @@ -37,6 +38,9 @@ builder.InitSqlSugarDb(new IocConfig() IsAutoCloseConnection = true, }); +// 消除Error unprotecting the session cookie警告 +builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(Directory.GetCurrentDirectory() + Path.DirectorySeparatorChar + "DataProtection")); // 配置Serilog Log.Logger = new LoggerConfiguration() @@ -61,6 +65,7 @@ builder.Services.AddControllers(options => builder.Services.AddEndpointsApiExplorer(); // 跨域配置 +builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider(); builder.AddCorsRegister(); //builder.Services.AddSession(); builder.Services.AddHttpClient();