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

@ -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(
};
}
/// <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>

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