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
This commit is contained in:
glz
2026-06-04 18:03:34 +08:00
parent 903ccd3073
commit ae2c6ddfc7
14 changed files with 1128 additions and 8 deletions

View File

@ -21,4 +21,20 @@ public interface ICheckInService : IBaseService<CheckInRecord>
/// <param name="userId">用户Id</param> /// <param name="userId">用户Id</param>
/// <returns>签到信息</returns> /// <returns>签到信息</returns>
Task<CheckInInfoOutput> GetCheckInInfoAsync(long userId); Task<CheckInInfoOutput> GetCheckInInfoAsync(long userId);
/// <summary>
/// 补签(消耗补签卡,补签历史漏签日期)
/// </summary>
/// <param name="userId">用户Id</param>
/// <param name="targetDate">补签目标日期</param>
/// <returns>补签结果</returns>
Task<CheckInOutput> MakeUpCheckInAsync(long userId, DateTime targetDate);
/// <summary>
/// 获取用户可补签的日期列表(历史漏签日期)
/// </summary>
/// <param name="userId">用户Id</param>
/// <param name="days">往前查看天数默认30天</param>
/// <returns>漏签日期列表</returns>
Task<List<DateTime>> GetMissedDatesAsync(long userId, int days = 30);
} }

View File

@ -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;
/// <summary>
/// 小程序商城服务接口
/// </summary>
public interface IWxMallService : IBaseService<ExchangeRecord>
{
/// <summary>
/// 获取商城商品列表(仅上架 + 在售商品)
/// </summary>
/// <param name="userId">当前用户Id用于判断是否已拥有</param>
/// <param name="type">按商品类型筛选(可选)</param>
Task<List<WxProductOutput>> GetProductsAsync(long userId, string? type = null);
/// <summary>
/// 获取商品详情
/// </summary>
/// <param name="userId">当前用户Id</param>
/// <param name="productId">商品Id</param>
Task<WxProductOutput?> GetProductDetailAsync(long userId, long productId);
/// <summary>
/// 积分兑换商品
/// </summary>
/// <param name="userId">用户Id</param>
/// <param name="input">兑换参数</param>
Task<ExchangeOutput> ExchangeAsync(long userId, ExchangeInput input);
/// <summary>
/// 获取用户兑换记录
/// </summary>
/// <param name="userId">用户Id</param>
/// <param name="limit">返回数量</param>
Task<List<ExchangeRecordOutput>> GetExchangeRecordsAsync(long userId, int limit = 20);
/// <summary>
/// 获取用户背包物品列表
/// </summary>
/// <param name="userId">用户Id</param>
/// <param name="itemType">按物品类型筛选(可选)</param>
Task<List<UserBagOutput>> GetBagItemsAsync(long userId, string? itemType = null);
/// <summary>
/// 使用背包物品(补签卡等消耗品)
/// </summary>
/// <param name="userId">用户Id</param>
/// <param name="input">使用参数</param>
Task<UseItemOutput> UseItemAsync(long userId, UseItemInput input);
/// <summary>
/// 宠物换肤
/// </summary>
/// <param name="userId">用户Id</param>
/// <param name="input">换肤参数</param>
Task EquipSkinAsync(long userId, EquipSkinInput input);
}

View File

@ -0,0 +1,87 @@
namespace QYZH.InteractiveMagazine.Models.Dto.Bag;
/// <summary>
/// 背包物品输出
/// </summary>
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;
/// <summary>
/// 关联商品信息(如果有)
/// </summary>
public BagProductBrief? Product { get; set; }
/// <summary>
/// 关联皮肤信息PetBg 类型时)
/// </summary>
public BagSkinBrief? Skin { get; set; }
public DateTime CreatedAt { get; set; }
}
/// <summary>
/// 背包中的商品简要
/// </summary>
public class BagProductBrief
{
public string Name { get; set; } = string.Empty;
public string? ImageUrl { get; set; }
public string? Description { get; set; }
}
/// <summary>
/// 背包中的皮肤简要
/// </summary>
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;
}
/// <summary>
/// 使用物品输入(补签卡)
/// </summary>
public class UseItemInput
{
/// <summary>
/// 背包物品Id
/// </summary>
public long BagItemId { get; set; }
/// <summary>
/// 补签目标日期yyyy-MM-dd仅补签卡使用
/// </summary>
public string? TargetDate { get; set; }
}
/// <summary>
/// 换肤输入
/// </summary>
public class EquipSkinInput
{
/// <summary>
/// 皮肤Id传0则恢复默认皮肤
/// </summary>
public long SkinId { get; set; }
}
/// <summary>
/// 使用物品输出
/// </summary>
public class UseItemOutput
{
public bool IsSuccess { get; set; }
public string Message { get; set; } = string.Empty;
/// <summary>
/// 补签时返回的签到结果
/// </summary>
public object? Extra { get; set; }
}

View File

@ -0,0 +1,80 @@
namespace QYZH.InteractiveMagazine.Models.Dto.Mall;
/// <summary>
/// 小程序商品展示输出
/// </summary>
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;
/// <summary>
/// 皮肤信息(仅 PetBg 类型有值)
/// </summary>
public PetSkinBrief? Skin { get; set; }
/// <summary>
/// 用户是否已拥有PetBg 类型时判断背包中是否有)
/// </summary>
public bool Owned { get; set; }
}
/// <summary>
/// 皮肤简要信息
/// </summary>
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;
}
/// <summary>
/// 兑换输入
/// </summary>
public class ExchangeInput
{
/// <summary>
/// 商品Id
/// </summary>
public long ProductId { get; set; }
/// <summary>
/// 兑换数量默认1
/// </summary>
public int Quantity { get; set; } = 1;
}
/// <summary>
/// 兑换输出
/// </summary>
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;
}
/// <summary>
/// 兑换记录输出
/// </summary>
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; }
}

View File

@ -0,0 +1,69 @@
using SqlSugar;
namespace QYZH.InteractiveMagazine.Models.Entity
{
/// <summary>
/// 商品兑换记录表
/// </summary>
[SugarTable("ExchangeRecord")]
public partial class ExchangeRecord : SqlSugarBaseEntity
{
public ExchangeRecord() { }
/// <summary>
/// Desc:用户Id
/// Default:
/// Nullable:False
/// </summary>
public long UserId { get; set; }
/// <summary>
/// Desc:商品Id
/// Default:
/// Nullable:False
/// </summary>
public long ProductId { get; set; }
/// <summary>
/// Desc:商品名称(快照)
/// Default:
/// Nullable:False
/// </summary>
public string ProductName { get; set; }
/// <summary>
/// Desc:商品类型(快照): MakeUpCard, PetBg
/// Default:
/// Nullable:False
/// </summary>
public string ProductType { get; set; }
/// <summary>
/// Desc:消耗积分
/// Default:
/// Nullable:False
/// </summary>
public int PointsCost { get; set; }
/// <summary>
/// Desc:兑换后积分余额
/// Default:
/// Nullable:False
/// </summary>
public int PointsBalance { get; set; }
/// <summary>
/// Desc:兑换数量
/// Default:1
/// Nullable:False
/// </summary>
public int Quantity { get; set; }
/// <summary>
/// Desc:状态: Success, Refunded
/// Default:Success
/// Nullable:False
/// </summary>
public new string Status { get; set; }
}
}

View File

@ -47,6 +47,13 @@ namespace QYZH.InteractiveMagazine.Models.Entity
/// </summary> /// </summary>
public int FeedingCount {get;set;} public int FeedingCount {get;set;}
/// <summary>
/// Desc:当前皮肤Id0为默认皮肤
/// Default:0
/// Nullable:False
/// </summary>
public long CurrentSkinId {get;set;}
/// <summary> /// <summary>
/// Desc:宠物类型 /// Desc:宠物类型
/// Default:Normal /// Default:Normal

View File

@ -0,0 +1,55 @@
using SqlSugar;
namespace QYZH.InteractiveMagazine.Models.Entity
{
/// <summary>
/// 宠物皮肤定义表
/// </summary>
[SugarTable("PetSkin")]
public partial class PetSkin : SqlSugarBaseEntity
{
public PetSkin() { }
/// <summary>
/// Desc:皮肤名称
/// Default:
/// Nullable:False
/// </summary>
public string Name { get; set; }
/// <summary>
/// Desc:皮肤图片
/// Default:
/// Nullable:True
/// </summary>
public string ImageUrl { get; set; }
/// <summary>
/// Desc:描述/特效说明
/// Default:
/// Nullable:True
/// </summary>
public string Description { get; set; }
/// <summary>
/// Desc:关联商品Id用于兑换
/// Default:
/// Nullable:False
/// </summary>
public long ProductId { get; set; }
/// <summary>
/// Desc:稀有度: Normal, Rare, Epic, Legendary
/// Default:Normal
/// Nullable:False
/// </summary>
public string Rarity { get; set; }
/// <summary>
/// Desc:排序权重
/// Default:0
/// Nullable:False
/// </summary>
public int SortOrder { get; set; }
}
}

View File

@ -31,7 +31,7 @@ namespace QYZH.InteractiveMagazine.Models.Entity
/// Default: /// Default:
/// Nullable:False /// Nullable:False
/// </summary> /// </summary>
public int ItemId {get;set;} public long ItemId {get;set;}
/// <summary> /// <summary>
/// Desc:数量 /// Desc:数量

View File

@ -192,11 +192,6 @@ public class CheckInService(
if (lastDate == today || lastDate == today.AddDays(-1)) if (lastDate == today || lastDate == today.AddDays(-1))
{ {
consecutiveDays = lastRecord.ConsecutiveDays; consecutiveDays = lastRecord.ConsecutiveDays;
if (lastDate == today)
{
// 今天已签到,连续天数就是今天的值
}
// 如果是昨天,则连续天数保持(今天还没签到)
} }
} }
@ -233,6 +228,160 @@ public class CheckInService(
}; };
} }
/// <summary>
/// 补签(消耗补签卡,补签历史漏签日期)
/// </summary>
public async Task<CheckInOutput> 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<CheckInRecord>()
.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<Users>()
.Where(u => u.Id == userId && !u.IsDeleted)
.FirstAsync();
if (user == null)
throw new BusinessException("用户不存在", 404);
// 查询宠物
var pet = await checkInRecordRepository.Context.Queryable<Pet>()
.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<Users>()
.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;
}
/// <summary>
/// 获取用户漏签日期列表
/// </summary>
public async Task<List<DateTime>> GetMissedDatesAsync(long userId, int days = 30)
{
var startDate = DateTime.Now.Date.AddDays(-days);
// 查询该时间段内所有签到记录
var checkedDates = await checkInRecordRepository.Context.Queryable<CheckInRecord>()
.Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate >= startDate)
.Select(r => r.CheckInDate.Date)
.ToListAsync();
var checkedDateSet = new HashSet<DateTime>(checkedDates);
var missedDates = new List<DateTime>();
// 遍历每一天,找出漏签的日期(排除今天,今天不算漏签)
for (var date = startDate; date < DateTime.Now.Date; date = date.AddDays(1))
{
if (!checkedDateSet.Contains(date))
{
missedDates.Add(date);
}
}
return missedDates;
}
/// <summary> /// <summary>
/// 计算连续签到天数 /// 计算连续签到天数
/// </summary> /// </summary>

View File

@ -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;
/// <summary>
/// 小程序商城服务实现
/// </summary>
public class WxMallService(
BaseRepository<ExchangeRecord> exchangeRecordRepository,
ICheckInService checkInService,
ILogger<WxMallService> logger)
: BaseRepository<ExchangeRecord>, IWxMallService
{
/// <summary>
/// 获取商城商品列表(仅上架 + 在售)
/// </summary>
public async Task<List<WxProductOutput>> GetProductsAsync(long userId, string? type = null)
{
var query = exchangeRecordRepository.Context.Queryable<Product>()
.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<PetSkin>()
.Where(s => skinProductIds.Contains(s.ProductId) && !s.IsDeleted)
.ToListAsync()
: new List<PetSkin>();
// 批量查询用户背包(判断已拥有)
var bagItems = skinProductIds.Count > 0
? await exchangeRecordRepository.Context.Queryable<UserBag>()
.Where(b => b.UserId == userId && !b.IsDeleted && b.Status == "Available"
&& skinProductIds.Contains(b.ItemId))
.ToListAsync()
: new List<UserBag>();
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();
}
/// <summary>
/// 获取商品详情
/// </summary>
public async Task<WxProductOutput?> GetProductDetailAsync(long userId, long productId)
{
var product = await exchangeRecordRepository.Context.Queryable<Product>()
.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<PetSkin>()
.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<UserBag>()
.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
};
}
/// <summary>
/// 积分兑换商品
/// </summary>
public async Task<ExchangeOutput> 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<Product>()
.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<Users>()
.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<Users>()
.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<UserBag>()
.Where(b => b.UserId == userId && b.ItemId == product.Id && !b.IsDeleted && b.Status == "Available")
.FirstAsync();
if (existingBag != null)
{
// 已有同类物品,累加数量
await exchangeRecordRepository.Context.Updateable<UserBag>()
.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} 已放入背包"
};
}
/// <summary>
/// 获取用户兑换记录
/// </summary>
public async Task<List<ExchangeRecordOutput>> 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();
}
/// <summary>
/// 获取用户背包物品
/// </summary>
public async Task<List<UserBagOutput>> GetBagItemsAsync(long userId, string? itemType = null)
{
var query = exchangeRecordRepository.Context.Queryable<UserBag>()
.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<Product>()
.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<PetSkin>()
.Where(s => skinProductIds.Contains(s.ProductId) && !s.IsDeleted)
.ToListAsync()
: new List<PetSkin>();
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();
}
/// <summary>
/// 使用背包物品(目前仅支持补签卡)
/// </summary>
public async Task<UseItemOutput> 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<UserBag>()
.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);
}
}
/// <summary>
/// 使用补签卡
/// </summary>
private async Task<UseItemOutput> 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<UserBag>()
.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<UserBag>()
.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
};
}
/// <summary>
/// 宠物换肤
/// </summary>
public async Task EquipSkinAsync(long userId, EquipSkinInput input)
{
logger.LogInformation("宠物换肤UserId: {UserId}, SkinId: {SkinId}", userId, input.SkinId);
// 查询用户宠物
var pet = await exchangeRecordRepository.Context.Queryable<Pet>()
.Where(p => p.UserId == userId && !p.IsDeleted)
.FirstAsync();
if (pet == null)
throw new BusinessException("您还没有宠物", 404);
if (input.SkinId == 0)
{
// 恢复默认皮肤
await exchangeRecordRepository.Context.Updateable<Pet>()
.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<PetSkin>()
.Where(s => s.Id == input.SkinId && !s.IsDeleted)
.FirstAsync();
if (skin == null)
throw new BusinessException("皮肤不存在", 404);
// 校验背包中是否拥有该皮肤通过关联商品Id判断
var hasSkin = await exchangeRecordRepository.Context.Queryable<UserBag>()
.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<Pet>()
.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);
}
}

View File

@ -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;
/// <summary>
/// 小程序背包控制器
/// </summary>
public class BagController(IWxMallService mallService, ILogger<BagController> logger) : WeChatBaseController
{
/// <summary>
/// 获取背包物品列表
/// </summary>
/// <param name="itemType">物品类型筛选(可选): MakeUpCard, PetBg</param>
[HttpGet("items")]
public async Task<BaseResponse<List<UserBagOutput>>> 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);
}
/// <summary>
/// 使用背包物品(补签卡等消耗品)
/// </summary>
[HttpPost("useItem")]
public async Task<BaseResponse<UseItemOutput>> 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);
}
/// <summary>
/// 宠物换肤
/// </summary>
[HttpPost("equipSkin")]
public async Task<BaseResponse<object>> EquipSkin([FromBody] EquipSkinInput input)
{
var userId = GetCurrentUserId();
if (userId == null) return Fail("未获取到用户信息") as dynamic;
await mallService.EquipSkinAsync(userId.Value, input);
return Success<object>(null!, input.SkinId == 0 ? "已恢复默认皮肤" : "换肤成功");
}
}

View File

@ -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;
/// <summary>
/// 小程序商城控制器
/// </summary>
public class MallController(IWxMallService mallService, ILogger<MallController> logger) : WeChatBaseController
{
/// <summary>
/// 获取商城商品列表
/// </summary>
/// <param name="type">商品类型筛选(可选): MakeUpCard, PetBg</param>
[HttpGet("products")]
public async Task<BaseResponse<List<WxProductOutput>>> 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);
}
/// <summary>
/// 获取商品详情
/// </summary>
/// <param name="id">商品Id</param>
[HttpGet("product/{id}")]
public async Task<BaseResponse<WxProductOutput>> 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<WxProductOutput>.Fail(ResultCode.DENY, "商品不存在或已下架");
return Success(product);
}
/// <summary>
/// 积分兑换商品
/// </summary>
[HttpPost("exchange")]
public async Task<BaseResponse<ExchangeOutput>> 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);
}
/// <summary>
/// 获取我的兑换记录
/// </summary>
/// <param name="limit">返回数量默认20</param>
[HttpGet("exchangeRecords")]
public async Task<BaseResponse<List<ExchangeRecordOutput>>> 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);
}
}

View File

@ -11,9 +11,9 @@ COPY . .
ENV TZ=Asia/Shanghai ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 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"] ENTRYPOINT ["dotnet", "QYZH.InteractiveMagazine.WebApi.dll"]

View File

@ -1,6 +1,7 @@
using Autofac; using Autofac;
using Autofac.Extensions.DependencyInjection; using Autofac.Extensions.DependencyInjection;
using BCrypt.Net; using BCrypt.Net;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.OpenApi; using Microsoft.OpenApi;
@ -37,6 +38,9 @@ builder.InitSqlSugarDb(new IocConfig()
IsAutoCloseConnection = true, IsAutoCloseConnection = true,
}); });
// 消除Error unprotecting the session cookie警告
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Directory.GetCurrentDirectory() + Path.DirectorySeparatorChar + "DataProtection"));
// 配置Serilog // 配置Serilog
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
@ -61,6 +65,7 @@ builder.Services.AddControllers(options =>
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
// 跨域配置 // 跨域配置
builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider();
builder.AddCorsRegister(); builder.AddCorsRegister();
//builder.Services.AddSession(); //builder.Services.AddSession();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();