312
This commit is contained in:
parent
1a799caadc
commit
2a52aacc0c
35
server/HoneyBox/scripts/create_user_poster_cache.sql
Normal file
35
server/HoneyBox/scripts/create_user_poster_cache.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
-- 创建用户海报缓存表
|
||||
-- 用于存储用户生成的推广海报,避免重复生成
|
||||
|
||||
IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[user_poster_cache]') AND type in (N'U'))
|
||||
BEGIN
|
||||
CREATE TABLE [dbo].[user_poster_cache] (
|
||||
[id] INT IDENTITY(1,1) NOT NULL,
|
||||
[user_id] INT NOT NULL,
|
||||
[app_id] NVARCHAR(100) NULL,
|
||||
[template_hash] NVARCHAR(64) NOT NULL,
|
||||
[cos_url] NVARCHAR(500) NOT NULL,
|
||||
[file_size] BIGINT NOT NULL DEFAULT 0,
|
||||
[mime_type] NVARCHAR(50) NOT NULL DEFAULT 'image/png',
|
||||
[status] INT NOT NULL DEFAULT 1,
|
||||
[expires_at] DATETIME2 NOT NULL,
|
||||
[platform] NVARCHAR(50) NOT NULL DEFAULT 'MP-WEIXIN',
|
||||
[created_at] DATETIME2 NOT NULL DEFAULT GETDATE(),
|
||||
[updated_at] DATETIME2 NOT NULL DEFAULT GETDATE(),
|
||||
CONSTRAINT [pk_user_poster_cache] PRIMARY KEY CLUSTERED ([id] ASC)
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX [ix_user_poster_cache_user_id] ON [dbo].[user_poster_cache] ([user_id]);
|
||||
CREATE INDEX [ix_user_poster_cache_template_hash] ON [dbo].[user_poster_cache] ([template_hash]);
|
||||
CREATE INDEX [ix_user_poster_cache_platform] ON [dbo].[user_poster_cache] ([platform]);
|
||||
CREATE INDEX [ix_user_poster_cache_status] ON [dbo].[user_poster_cache] ([status]);
|
||||
CREATE INDEX [ix_user_poster_cache_expires_at] ON [dbo].[user_poster_cache] ([expires_at]);
|
||||
|
||||
PRINT '表 user_poster_cache 创建成功';
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
PRINT '表 user_poster_cache 已存在';
|
||||
END
|
||||
GO
|
||||
|
|
@ -427,6 +427,36 @@ public class BaseSetting
|
|||
[JsonPropertyName("share_image")]
|
||||
public string? ShareImage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 海报模板图片URL
|
||||
/// </summary>
|
||||
[JsonPropertyName("poster_template")]
|
||||
public string? PosterTemplate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 海报二维码X坐标
|
||||
/// </summary>
|
||||
[JsonPropertyName("poster_qr_x")]
|
||||
public string? PosterQrX { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 海报二维码Y坐标
|
||||
/// </summary>
|
||||
[JsonPropertyName("poster_qr_y")]
|
||||
public string? PosterQrY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 海报二维码大小
|
||||
/// </summary>
|
||||
[JsonPropertyName("poster_qr_size")]
|
||||
public string? PosterQrSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 站点URL
|
||||
/// </summary>
|
||||
[JsonPropertyName("site_url")]
|
||||
public string? SiteUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 抽奖券拉人上限
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -73,6 +73,16 @@ export interface BaseSetting {
|
|||
share_title?: string
|
||||
/** 分享图片 */
|
||||
share_image?: string
|
||||
/** 海报模板图片URL */
|
||||
poster_template?: string
|
||||
/** 海报二维码X坐标 */
|
||||
poster_qr_x?: string
|
||||
/** 海报二维码Y坐标 */
|
||||
poster_qr_y?: string
|
||||
/** 海报二维码大小 */
|
||||
poster_qr_size?: string
|
||||
/** 站点URL */
|
||||
site_url?: string
|
||||
/** 抽奖券拉人上限 */
|
||||
draw_people_num?: string
|
||||
/** 首页是否弹窗 0关闭 1开启 */
|
||||
|
|
|
|||
|
|
@ -237,6 +237,70 @@
|
|||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 海报配置 -->
|
||||
<el-divider content-position="left">海报配置</el-divider>
|
||||
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="海报模板图片" prop="poster_template">
|
||||
<ImageUpload
|
||||
v-model="formData.poster_template"
|
||||
placeholder="点击上传海报模板"
|
||||
:show-url-input="true"
|
||||
tip="推荐尺寸 750x1334,二维码将合成到模板上"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="站点URL" prop="site_url">
|
||||
<el-input
|
||||
v-model="formData.site_url"
|
||||
placeholder="请输入站点URL,如 https://example.com"
|
||||
/>
|
||||
<div class="form-tip">用于生成海报二维码的推广链接</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="二维码X坐标" prop="poster_qr_x">
|
||||
<el-input-number
|
||||
v-model.number="formData.poster_qr_x"
|
||||
:min="0"
|
||||
:max="2000"
|
||||
placeholder="二维码左上角X坐标"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="form-tip">二维码在海报上的X坐标(像素)</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="二维码Y坐标" prop="poster_qr_y">
|
||||
<el-input-number
|
||||
v-model.number="formData.poster_qr_y"
|
||||
:min="0"
|
||||
:max="3000"
|
||||
placeholder="二维码左上角Y坐标"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="form-tip">二维码在海报上的Y坐标(像素)</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="二维码大小" prop="poster_qr_size">
|
||||
<el-input-number
|
||||
v-model.number="formData.poster_qr_size"
|
||||
:min="50"
|
||||
:max="500"
|
||||
placeholder="二维码大小"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="form-tip">二维码宽高(像素)</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 开关配置 -->
|
||||
<el-divider content-position="left">开关配置</el-divider>
|
||||
|
||||
|
|
@ -303,6 +367,11 @@ interface FormDataType {
|
|||
erweima: string
|
||||
share_title: string
|
||||
share_image: string
|
||||
poster_template: string
|
||||
poster_qr_x: number
|
||||
poster_qr_y: number
|
||||
poster_qr_size: number
|
||||
site_url: string
|
||||
draw_people_num: number
|
||||
is_shou_tan: number
|
||||
is_exchange: number
|
||||
|
|
@ -325,6 +394,11 @@ const formData = reactive<FormDataType>({
|
|||
erweima: '',
|
||||
share_title: '',
|
||||
share_image: '',
|
||||
poster_template: '',
|
||||
poster_qr_x: 104,
|
||||
poster_qr_y: 1180,
|
||||
poster_qr_size: 200,
|
||||
site_url: '',
|
||||
draw_people_num: 10,
|
||||
is_shou_tan: 0,
|
||||
is_exchange: 1
|
||||
|
|
@ -372,6 +446,11 @@ const loadData = async () => {
|
|||
erweima: data.erweima || '',
|
||||
share_title: data.share_title || '',
|
||||
share_image: data.share_image || '',
|
||||
poster_template: data.poster_template || '',
|
||||
poster_qr_x: Number(data.poster_qr_x) || 104,
|
||||
poster_qr_y: Number(data.poster_qr_y) || 1180,
|
||||
poster_qr_size: Number(data.poster_qr_size) || 200,
|
||||
site_url: data.site_url || '',
|
||||
draw_people_num: Number(data.draw_people_num) || 10,
|
||||
is_shou_tan: Number(data.is_shou_tan) || 0,
|
||||
is_exchange: Number(data.is_exchange) || 1
|
||||
|
|
@ -416,6 +495,11 @@ const handleSave = async () => {
|
|||
erweima: formData.erweima,
|
||||
share_title: formData.share_title,
|
||||
share_image: formData.share_image,
|
||||
poster_template: formData.poster_template,
|
||||
poster_qr_x: String(formData.poster_qr_x),
|
||||
poster_qr_y: String(formData.poster_qr_y),
|
||||
poster_qr_size: String(formData.poster_qr_size),
|
||||
site_url: formData.site_url,
|
||||
draw_people_num: String(formData.draw_people_num),
|
||||
is_shou_tan: String(formData.is_shou_tan),
|
||||
is_exchange: String(formData.is_exchange)
|
||||
|
|
|
|||
|
|
@ -30,12 +30,12 @@ public class InvitationController : ControllerBase
|
|||
|
||||
/// <summary>
|
||||
/// 获取推荐信息
|
||||
/// POST /api/invitation
|
||||
/// GET /api/invitation
|
||||
/// Requirements: 9.1-9.2
|
||||
/// </summary>
|
||||
[HttpGet("invitation")]
|
||||
[Authorize]
|
||||
public async Task<ApiResponse<InvitationInfoResponse>> GetInvitationInfo([FromBody] InvitationInfoRequest? request)
|
||||
public async Task<ApiResponse<InvitationInfoResponse>> GetInvitationInfo([FromQuery] int page = 1, [FromQuery] int pageSize = 10)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
|
|
@ -45,10 +45,16 @@ public class InvitationController : ControllerBase
|
|||
|
||||
try
|
||||
{
|
||||
var page = request?.Page ?? 1;
|
||||
if (page < 1) page = 1;
|
||||
|
||||
var result = await _invitationService.GetInvitationInfoAsync(userId.Value, page);
|
||||
// 从请求头获取平台类型
|
||||
var platform = Request.Headers["client"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(platform))
|
||||
{
|
||||
platform = Request.Headers["platform"].FirstOrDefault() ?? "H5";
|
||||
}
|
||||
|
||||
var result = await _invitationService.GetInvitationInfoAsync(userId.Value, page, platform);
|
||||
return ApiResponse<InvitationInfoResponse>.Success(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ public interface IInvitationService
|
|||
/// </summary>
|
||||
/// <param name="userId">用户ID</param>
|
||||
/// <param name="page">页码</param>
|
||||
/// <param name="platform">平台类型</param>
|
||||
/// <returns>推荐信息响应</returns>
|
||||
Task<InvitationInfoResponse> GetInvitationInfoAsync(int userId, int page);
|
||||
Task<InvitationInfoResponse> GetInvitationInfoAsync(int userId, int page, string? platform = null);
|
||||
|
||||
/// <summary>
|
||||
/// 绑定邀请码
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
namespace HoneyBox.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 海报生成服务接口
|
||||
/// </summary>
|
||||
public interface IPosterService
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或生成用户推广海报
|
||||
/// </summary>
|
||||
/// <param name="userId">用户ID</param>
|
||||
/// <param name="platform">平台类型(MP-WEIXIN/H5/APP等)</param>
|
||||
/// <returns>海报生成结果</returns>
|
||||
Task<PosterResult> GetUserPosterAsync(int userId, string? platform = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 海报生成结果
|
||||
/// </summary>
|
||||
public class PosterResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否成功
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消息
|
||||
/// </summary>
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 海报图片URL
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
}
|
||||
|
|
@ -13,17 +13,22 @@ namespace HoneyBox.Core.Services;
|
|||
public class InvitationService : IInvitationService
|
||||
{
|
||||
private readonly HoneyBoxDbContext _dbContext;
|
||||
private readonly IPosterService _posterService;
|
||||
private readonly ILogger<InvitationService> _logger;
|
||||
private const int PageSize = 15;
|
||||
|
||||
public InvitationService(HoneyBoxDbContext dbContext, ILogger<InvitationService> logger)
|
||||
public InvitationService(
|
||||
HoneyBoxDbContext dbContext,
|
||||
IPosterService posterService,
|
||||
ILogger<InvitationService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_posterService = posterService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<InvitationInfoResponse> GetInvitationInfoAsync(int userId, int page)
|
||||
public async Task<InvitationInfoResponse> GetInvitationInfoAsync(int userId, int page, string? platform = null)
|
||||
{
|
||||
// 获取被邀请用户列表(pid = userId 且 status = 1)
|
||||
var query = _dbContext.Users
|
||||
|
|
@ -72,7 +77,20 @@ public class InvitationService : IInvitationService
|
|||
|
||||
// 获取分享配置
|
||||
var shareTitle = await GetShareTitleAsync();
|
||||
var shareImage = await GetShareImageAsync();
|
||||
|
||||
// 生成用户专属海报
|
||||
var shareImage = string.Empty;
|
||||
var posterResult = await _posterService.GetUserPosterAsync(userId, platform);
|
||||
if (posterResult.Success && !string.IsNullOrEmpty(posterResult.ImageUrl))
|
||||
{
|
||||
shareImage = posterResult.ImageUrl;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果海报生成失败,使用静态分享图片作为备选
|
||||
shareImage = await GetShareImageAsync();
|
||||
_logger.LogWarning("用户 {UserId} 海报生成失败: {Message},使用静态分享图片", userId, posterResult.Message);
|
||||
}
|
||||
|
||||
return new InvitationInfoResponse
|
||||
{
|
||||
|
|
|
|||
454
server/HoneyBox/src/HoneyBox.Infrastructure/External/Poster/PosterService.cs
vendored
Normal file
454
server/HoneyBox/src/HoneyBox.Infrastructure/External/Poster/PosterService.cs
vendored
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using HoneyBox.Core.Interfaces;
|
||||
using HoneyBox.Model.Data;
|
||||
using HoneyBox.Model.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using QRCoder;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace HoneyBox.Infrastructure.External.Poster;
|
||||
|
||||
/// <summary>
|
||||
/// 海报生成服务实现
|
||||
/// </summary>
|
||||
public class PosterService : IPosterService
|
||||
{
|
||||
private readonly HoneyBoxDbContext _dbContext;
|
||||
private readonly IImageUploadService _imageUploadService;
|
||||
private readonly ILogger<PosterService> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
// 默认缓存100天
|
||||
private const int DefaultCacheExpireDays = 100;
|
||||
// 二维码在海报上的位置(可通过配置调整)
|
||||
private const int DefaultQrCodeX = 104;
|
||||
private const int DefaultQrCodeY = 1180;
|
||||
private const int DefaultQrCodeSize = 200;
|
||||
|
||||
public PosterService(
|
||||
HoneyBoxDbContext dbContext,
|
||||
IImageUploadService imageUploadService,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<PosterService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_imageUploadService = imageUploadService;
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PosterResult> GetUserPosterAsync(int userId, string? platform = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 默认平台
|
||||
platform ??= "H5";
|
||||
|
||||
// 1. 验证用户ID
|
||||
if (userId <= 0)
|
||||
{
|
||||
return new PosterResult { Success = false, Message = "无效的用户ID" };
|
||||
}
|
||||
|
||||
// 2. 获取海报配置
|
||||
var posterConfig = await GetPosterConfigAsync();
|
||||
if (posterConfig == null || string.IsNullOrEmpty(posterConfig.TemplateUrl))
|
||||
{
|
||||
return new PosterResult { Success = false, Message = "海报模板未配置" };
|
||||
}
|
||||
|
||||
// 3. 计算模板哈希(用于缓存失效判断)
|
||||
var templateHash = ComputeHash(posterConfig.TemplateUrl);
|
||||
|
||||
// 4. 获取小程序配置(用于生成小程序码)
|
||||
var appId = await GetDefaultAppIdAsync();
|
||||
|
||||
// 5. 查询缓存记录
|
||||
var cacheRecord = await FindValidCacheAsync(userId, templateHash, appId, platform);
|
||||
if (cacheRecord != null)
|
||||
{
|
||||
_logger.LogInformation("用户 {UserId} 海报缓存命中", userId);
|
||||
return new PosterResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "海报获取成功",
|
||||
ImageUrl = cacheRecord.CosUrl
|
||||
};
|
||||
}
|
||||
|
||||
// 6. 生成推广链接
|
||||
var qrContent = await GeneratePromotionUrlAsync(userId, platform);
|
||||
if (string.IsNullOrEmpty(qrContent))
|
||||
{
|
||||
return new PosterResult { Success = false, Message = "生成推广链接失败" };
|
||||
}
|
||||
|
||||
// 7. 下载海报模板
|
||||
var templateBytes = await DownloadImageAsync(posterConfig.TemplateUrl);
|
||||
if (templateBytes == null)
|
||||
{
|
||||
return new PosterResult { Success = false, Message = "下载海报模板失败" };
|
||||
}
|
||||
|
||||
// 8. 生成带二维码的海报
|
||||
var posterBytes = GeneratePosterWithQrCode(
|
||||
templateBytes,
|
||||
qrContent,
|
||||
posterConfig.QrCodeX ?? DefaultQrCodeX,
|
||||
posterConfig.QrCodeY ?? DefaultQrCodeY,
|
||||
posterConfig.QrCodeSize ?? DefaultQrCodeSize);
|
||||
|
||||
if (posterBytes == null)
|
||||
{
|
||||
return new PosterResult { Success = false, Message = "海报生成失败" };
|
||||
}
|
||||
|
||||
// 9. 上传到COS
|
||||
using var stream = new MemoryStream(posterBytes);
|
||||
var cosUrl = await _imageUploadService.UploadStreamAsync(
|
||||
stream,
|
||||
$"poster_{userId}_{platform}.png",
|
||||
"image/png",
|
||||
"poster");
|
||||
|
||||
if (string.IsNullOrEmpty(cosUrl))
|
||||
{
|
||||
return new PosterResult { Success = false, Message = "海报上传失败" };
|
||||
}
|
||||
|
||||
// 10. 失效旧缓存
|
||||
await InvalidateOldCachesAsync(userId, templateHash, platform);
|
||||
|
||||
// 11. 保存新缓存记录
|
||||
var newCache = new UserPosterCache
|
||||
{
|
||||
UserId = userId,
|
||||
AppId = appId,
|
||||
TemplateHash = templateHash,
|
||||
CosUrl = cosUrl,
|
||||
FileSize = posterBytes.Length,
|
||||
MimeType = "image/png",
|
||||
Status = 1,
|
||||
ExpiresAt = DateTime.Now.AddDays(DefaultCacheExpireDays),
|
||||
Platform = platform,
|
||||
CreatedAt = DateTime.Now,
|
||||
UpdatedAt = DateTime.Now
|
||||
};
|
||||
_dbContext.Set<UserPosterCache>().Add(newCache);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// 12. 随机清理过期缓存(5%概率)
|
||||
if (Random.Shared.Next(100) < 5)
|
||||
{
|
||||
_ = CleanExpiredCachesAsync();
|
||||
}
|
||||
|
||||
_logger.LogInformation("用户 {UserId} 海报生成成功: {Url}", userId, cosUrl);
|
||||
return new PosterResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "海报生成成功",
|
||||
ImageUrl = cosUrl
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "获取用户海报异常: UserId={UserId}", userId);
|
||||
return new PosterResult { Success = false, Message = "系统异常,请稍后重试" };
|
||||
}
|
||||
}
|
||||
|
||||
#region 私有方法
|
||||
|
||||
/// <summary>
|
||||
/// 获取海报配置
|
||||
/// </summary>
|
||||
private async Task<PosterConfig?> GetPosterConfigAsync()
|
||||
{
|
||||
var config = await _dbContext.Configs
|
||||
.FirstOrDefaultAsync(c => c.ConfigKey == "base");
|
||||
|
||||
if (config?.ConfigValue == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(config.ConfigValue);
|
||||
return new PosterConfig
|
||||
{
|
||||
TemplateUrl = GetJsonString(jsonDoc, "poster_template"),
|
||||
QrCodeX = GetJsonInt(jsonDoc, "poster_qr_x"),
|
||||
QrCodeY = GetJsonInt(jsonDoc, "poster_qr_y"),
|
||||
QrCodeSize = GetJsonInt(jsonDoc, "poster_qr_size"),
|
||||
SiteUrl = GetJsonString(jsonDoc, "site_url")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "解析海报配置失败");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取默认小程序AppId
|
||||
/// </summary>
|
||||
private async Task<string> GetDefaultAppIdAsync()
|
||||
{
|
||||
var config = await _dbContext.Configs
|
||||
.FirstOrDefaultAsync(c => c.ConfigKey == "miniprogram_setting");
|
||||
|
||||
if (config?.ConfigValue == null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(config.ConfigValue);
|
||||
if (jsonDoc.TryGetProperty("miniprograms", out var miniprograms) &&
|
||||
miniprograms.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var mp in miniprograms.EnumerateArray())
|
||||
{
|
||||
if (mp.TryGetProperty("is_default", out var isDefault) &&
|
||||
isDefault.GetInt32() == 1 &&
|
||||
mp.TryGetProperty("appid", out var appid))
|
||||
{
|
||||
return appid.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "解析小程序配置失败");
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成推广链接
|
||||
/// </summary>
|
||||
private async Task<string> GeneratePromotionUrlAsync(int userId, string platform)
|
||||
{
|
||||
// 获取站点URL配置
|
||||
var config = await _dbContext.Configs
|
||||
.FirstOrDefaultAsync(c => c.ConfigKey == "base");
|
||||
|
||||
var siteUrl = "https://zfunbox.cn";
|
||||
if (config?.ConfigValue != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(config.ConfigValue);
|
||||
if (jsonDoc.TryGetProperty("site_url", out var siteUrlProp))
|
||||
{
|
||||
var url = siteUrlProp.GetString();
|
||||
if (!string.IsNullOrEmpty(url))
|
||||
{
|
||||
siteUrl = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// H5和APP使用URL链接
|
||||
return $"{siteUrl}?pid={userId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下载图片
|
||||
/// </summary>
|
||||
private async Task<byte[]?> DownloadImageAsync(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return await response.Content.ReadAsByteArrayAsync();
|
||||
}
|
||||
_logger.LogWarning("下载图片失败: {Url}, StatusCode: {StatusCode}", url, response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "下载图片异常: {Url}", url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成带二维码的海报
|
||||
/// </summary>
|
||||
private byte[]? GeneratePosterWithQrCode(byte[] templateBytes, string qrContent, int qrX, int qrY, int qrSize)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 加载模板图片
|
||||
using var templateBitmap = SKBitmap.Decode(templateBytes);
|
||||
if (templateBitmap == null)
|
||||
{
|
||||
_logger.LogWarning("无法解码海报模板图片");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 生成二维码
|
||||
using var qrGenerator = new QRCodeGenerator();
|
||||
var qrCodeData = qrGenerator.CreateQrCode(qrContent, QRCodeGenerator.ECCLevel.M);
|
||||
using var qrCode = new PngByteQRCode(qrCodeData);
|
||||
var qrCodeBytes = qrCode.GetGraphic(20);
|
||||
|
||||
// 解码二维码图片
|
||||
using var qrBitmap = SKBitmap.Decode(qrCodeBytes);
|
||||
if (qrBitmap == null)
|
||||
{
|
||||
_logger.LogWarning("无法解码二维码图片");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 缩放二维码到指定大小
|
||||
using var scaledQrBitmap = qrBitmap.Resize(new SKImageInfo(qrSize, qrSize), SKFilterQuality.High);
|
||||
|
||||
// 创建画布
|
||||
using var surface = SKSurface.Create(new SKImageInfo(templateBitmap.Width, templateBitmap.Height));
|
||||
var canvas = surface.Canvas;
|
||||
|
||||
// 绘制模板
|
||||
canvas.DrawBitmap(templateBitmap, 0, 0);
|
||||
|
||||
// 绘制二维码
|
||||
canvas.DrawBitmap(scaledQrBitmap, qrX, qrY);
|
||||
|
||||
// 导出为PNG
|
||||
using var image = surface.Snapshot();
|
||||
using var data = image.Encode(SKEncodedImageFormat.Png, 90);
|
||||
return data.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "生成海报失败");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找有效缓存
|
||||
/// </summary>
|
||||
private async Task<UserPosterCache?> FindValidCacheAsync(int userId, string templateHash, string appId, string platform)
|
||||
{
|
||||
return await _dbContext.Set<UserPosterCache>()
|
||||
.Where(c => c.UserId == userId)
|
||||
.Where(c => c.TemplateHash == templateHash)
|
||||
.Where(c => c.Platform == platform)
|
||||
.Where(c => c.Status == 1)
|
||||
.Where(c => c.ExpiresAt > DateTime.Now)
|
||||
.OrderByDescending(c => c.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 失效旧缓存
|
||||
/// </summary>
|
||||
private async Task InvalidateOldCachesAsync(int userId, string templateHash, string platform)
|
||||
{
|
||||
var oldCaches = await _dbContext.Set<UserPosterCache>()
|
||||
.Where(c => c.UserId == userId)
|
||||
.Where(c => c.Platform == platform)
|
||||
.Where(c => c.TemplateHash != templateHash || c.Status == 1)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var cache in oldCaches)
|
||||
{
|
||||
cache.Status = 0;
|
||||
cache.UpdatedAt = DateTime.Now;
|
||||
}
|
||||
|
||||
if (oldCaches.Count > 0)
|
||||
{
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理过期缓存
|
||||
/// </summary>
|
||||
private async Task CleanExpiredCachesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var expiredCaches = await _dbContext.Set<UserPosterCache>()
|
||||
.Where(c => c.ExpiresAt < DateTime.Now || c.Status == 0)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
|
||||
if (expiredCaches.Count > 0)
|
||||
{
|
||||
_dbContext.Set<UserPosterCache>().RemoveRange(expiredCaches);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
_logger.LogInformation("清理了 {Count} 条过期海报缓存", expiredCaches.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "清理过期海报缓存失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算字符串哈希
|
||||
/// </summary>
|
||||
private static string ComputeHash(string input)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
|
||||
var hash = MD5.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? GetJsonString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return prop.GetString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? GetJsonInt(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var prop))
|
||||
{
|
||||
if (prop.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
return prop.GetInt32();
|
||||
}
|
||||
if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out var val))
|
||||
{
|
||||
return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// 海报配置
|
||||
/// </summary>
|
||||
private class PosterConfig
|
||||
{
|
||||
public string? TemplateUrl { get; set; }
|
||||
public int? QrCodeX { get; set; }
|
||||
public int? QrCodeY { get; set; }
|
||||
public int? QrCodeSize { get; set; }
|
||||
public string? SiteUrl { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@
|
|||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||
<PackageReference Include="SkiaSharp" Version="2.88.8" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.10.1" />
|
||||
<PackageReference Include="Tencent.QCloud.Cos.Sdk" Version="5.4.44" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Autofac;
|
||||
using HoneyBox.Core.Interfaces;
|
||||
using HoneyBox.Infrastructure.Cache;
|
||||
using HoneyBox.Infrastructure.External.Poster;
|
||||
using HoneyBox.Infrastructure.External.Storage;
|
||||
|
||||
namespace HoneyBox.Infrastructure.Modules;
|
||||
|
|
@ -27,6 +28,11 @@ public class InfrastructureModule : Module
|
|||
.As<IImageUploadService>()
|
||||
.InstancePerLifetimeScope();
|
||||
|
||||
// 注册海报生成服务
|
||||
builder.RegisterType<PosterService>()
|
||||
.As<IPosterService>()
|
||||
.InstancePerLifetimeScope();
|
||||
|
||||
// 后续可在此注册其他基础设施服务
|
||||
// 如: 外部服务客户端、消息队列等
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,8 +101,9 @@ public class ServiceModule : Module
|
|||
builder.Register(c =>
|
||||
{
|
||||
var dbContext = c.Resolve<HoneyBoxDbContext>();
|
||||
var posterService = c.Resolve<IPosterService>();
|
||||
var logger = c.Resolve<ILogger<InvitationService>>();
|
||||
return new InvitationService(dbContext, logger);
|
||||
return new InvitationService(dbContext, posterService, logger);
|
||||
}).As<IInvitationService>().InstancePerLifetimeScope();
|
||||
|
||||
// 注册排行榜服务
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ public partial class HoneyBoxDbContext : DbContext
|
|||
|
||||
public virtual DbSet<PrizeAnnouncement> PrizeAnnouncements { get; set; }
|
||||
|
||||
public virtual DbSet<UserPosterCache> UserPosterCaches { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
// Connection string is configured in Program.cs via dependency injection
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace HoneyBox.Model.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 用户海报缓存实体
|
||||
/// </summary>
|
||||
[Table("user_poster_cache")]
|
||||
public class UserPosterCache
|
||||
{
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户ID
|
||||
/// </summary>
|
||||
[Column("user_id")]
|
||||
public int UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 小程序AppId
|
||||
/// </summary>
|
||||
[Column("app_id")]
|
||||
[StringLength(100)]
|
||||
public string? AppId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板文件哈希值
|
||||
/// </summary>
|
||||
[Column("template_hash")]
|
||||
[StringLength(64)]
|
||||
public string TemplateHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// COS存储URL
|
||||
/// </summary>
|
||||
[Column("cos_url")]
|
||||
[StringLength(500)]
|
||||
public string CosUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小(字节)
|
||||
/// </summary>
|
||||
[Column("file_size")]
|
||||
public long FileSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME类型
|
||||
/// </summary>
|
||||
[Column("mime_type")]
|
||||
[StringLength(50)]
|
||||
public string MimeType { get; set; } = "image/png";
|
||||
|
||||
/// <summary>
|
||||
/// 状态:1有效 0无效
|
||||
/// </summary>
|
||||
[Column("status")]
|
||||
public int Status { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间
|
||||
/// </summary>
|
||||
[Column("expires_at")]
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平台类型:MP-WEIXIN/H5/APP等
|
||||
/// </summary>
|
||||
[Column("platform")]
|
||||
[StringLength(50)]
|
||||
public string Platform { get; set; } = "MP-WEIXIN";
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间
|
||||
/// </summary>
|
||||
[Column("updated_at")]
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user