From d8313d78223c6fc5bf19273e8766e526a74f4482 Mon Sep 17 00:00:00 2001 From: zhangrui Date: Tue, 3 Nov 2026 17:57:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=93=81=E7=89=8C=E6=B4=BB=E5=8A=A8=E8=B0=83?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jmeter/README.md | 90 ++++ .../brand-activity-mountain-signup-1300.jmx | 291 +++++++++++++ jmeter/prepare-brand-activity-users.ps1 | 132 ++++++ .../app/base/constant/RedisConstant.java | 3 + .../com/budwk/app/base/sms/SmsService.java | 3 + .../app/base/sms/impl/SmsServiceImpl.java | 321 ++++++++++++++ .../sms/impl/jshvc/SmsJshvcServiceImpl.java | 182 -------- .../app/sys/controller/SysFileController.java | 39 ++ .../com/budwk/app/sys/models/Sys_user.java | 12 + .../app/sys/services/SysFileService.java | 16 + .../impl/SysDataUserAllUpdateServiceImpl.java | 10 +- .../sys/services/impl/SysFileServiceImpl.java | 406 ++++++++++++++++++ .../manage/TrainSignUpManageController.java | 55 ++- .../manage/TrainsignUpMineController.java | 59 ++- .../models/TrainSignUpActivity.java | 15 + .../service/TrainSignUpActivityService.java | 35 ++ .../impl/TrainSignUpActivityServiceImpl.java | 341 +++++++++++++++ .../budwk/app/zhgh/map/TMapController.java | 2 +- .../WelfareProjectMangeController.java | 25 ++ .../welfare/service/WelfareListService.java | 8 + .../service/WelfareProjectService.java | 6 + .../service/impl/WelfareListServiceImpl.java | 25 ++ .../impl/WelfareProjectServiceImpl.java | 65 +++ .../static/assets/mobile/js/popupHistory.js | 67 +++ .../trainSignUp/TrainSignDynamicForm.vue | 107 +++++ .../components/plugins/mapContainer/TMap.vue | 3 +- .../plugins/sysTextEditor/index.vue | 255 ++++++----- .../resources/views/layouts/platform_h5.html | 8 +- .../views/layouts/v4/baseLayout.html | 2 +- .../activity/trainSignUp/manage/basicForm.js | 138 +++++- .../activity/trainSignUp/manage/courseTime.js | 8 +- .../activity/trainSignUp/manage/index.html | 105 +++++ .../zhgh/activity/trainSignUp/manage/info.js | 23 + .../zhgh/welfare/projectMange/index.html | 72 +++- .../welfare/projectMange/welfareOption.js | 5 +- .../activity/trainSignUp/scan/index.html | 271 +++++++----- .../platform/zhghh5/welfare/select/index.html | 305 ++++++++++++- 37 files changed, 3044 insertions(+), 466 deletions(-) create mode 100644 jmeter/README.md create mode 100644 jmeter/brand-activity-mountain-signup-1300.jmx create mode 100644 jmeter/prepare-brand-activity-users.ps1 create mode 100644 src/main/java/com/budwk/app/base/sms/impl/SmsServiceImpl.java delete mode 100644 src/main/java/com/budwk/app/base/sms/impl/jshvc/SmsJshvcServiceImpl.java create mode 100644 src/main/resources/static/assets/mobile/js/popupHistory.js create mode 100644 src/main/resources/static/components/module/trainSignUp/TrainSignDynamicForm.vue diff --git a/jmeter/README.md b/jmeter/README.md new file mode 100644 index 0000000..8aaff65 --- /dev/null +++ b/jmeter/README.md @@ -0,0 +1,90 @@ +# 登山活动 1300 人并发报名压测 + +本目录用于本地或已明确授权的测试环境。JMeter 会真实调用登录和报名接口,并向报名表写入数据,禁止直接对生产环境执行。 + +## 文件说明 + +- `prepare-brand-activity-users.ps1`:从当前项目数据库导出活动范围内、未禁用且尚未报名的人员。 +- `brand-activity-mountain-signup-1300.jmx`:每线程独立登录,所有线程到达报名步骤后同时提交。 +- `data/brand-activity-users.csv`:运行准备脚本后生成,不包含密码、手机号、身份证号等敏感数据。 + +当前登山活动参数: + +- 活动 ID:`11619eee67574256ad8ccc8a16630023` +- 子活动 ID:`986bf4d48a5948e1810857483f9e5af2` +- 容量:1500 人 + +## 1. 准备人员数据 + +在项目根目录执行: + +```powershell +powershell -ExecutionPolicy Bypass -File .\jmeter\prepare-brand-activity-users.ps1 -UserCount 1300 -PermissionMode AllScope +``` + +`AllScope` 会导出1300名活动范围人员。根据当前数据库,约290名人员没有报名菜单权限,其报名请求会被权限校验拒绝。 + +如果只测试具备权限的人员: + +```powershell +powershell -ExecutionPolicy Bypass -File .\jmeter\prepare-brand-activity-users.ps1 -UserCount 1010 -PermissionMode Authorized +``` + +当前数据库只有1010名“有报名权限且尚未报名”的人员,因此 `Authorized` 模式不能生成1300名不同人员。 + +## 2. 先进行10人试跑 + +启动本地项目,确认80端口和 Redis 6378 可访问,然后执行: + +```powershell +jmeter -n -t .\jmeter\brand-activity-mountain-signup-1300.jmx ` + -Jthreads=10 ` + -JloginRampSeconds=10 ` + -Jhost=127.0.0.1 ` + -Jport=80 ` + -JusersFile=jmeter/data/brand-activity-users.csv ` + -l .\jmeter\result-smoke.jtl ` + -j .\jmeter\jmeter-smoke.log +``` + +试跑会真实产生报名记录。再次运行前必须重新生成 CSV,以排除已经报名的人员。 + +## 3. 执行1300人并发 + +确认测试数据库已备份、应用和 Redis 正常、活动仍在报名时间内后执行: + +```powershell +jmeter -n -t .\jmeter\brand-activity-mountain-signup-1300.jmx ` + -Jthreads=1300 ` + -JloginRampSeconds=60 ` + -JsyncTimeoutMs=300000 ` + -Jhost=127.0.0.1 ` + -Jport=80 ` + -JusersFile=jmeter/data/brand-activity-users.csv ` + -l .\jmeter\result-1300.jtl ` + -j .\jmeter\jmeter-1300.log +``` + +登录在60秒内逐步完成;所有仍在运行的线程会在“07-集合后并发提交登山活动报名”前等待,然后同时发送报名请求。 + +## 4. 生成结果报告 + +```powershell +jmeter -g .\jmeter\result-1300.jtl -o .\jmeter\report-1300 +``` + +重点查看: + +- `05-人员账号登录`:独立账号登录是否成功。 +- `06-报名资格预校验`:活动范围、时间、重复报名和名额检查。 +- `07-集合后并发提交登山活动报名`:真正的并发报名结果。 + +报名结果只有返回 `code=0` 且 `msg=报名成功` 才计为成功,避免后端异常分支返回成功状态码但消息为“报名失败”时被误判。 + +## 注意事项 + +- 登录验证码由应用写入本机 Redis,JMX 仅在授权测试环境中从 Redis 读取对应验证码答案。 +- 当前 `cas.enable=false`。现有登录实现只在 CAS 模式开启时拒绝密码不匹配,因此 JMX 使用测试占位密码;该行为不应复制到生产环境。 +- Controller 使用单 JVM `ReentrantLock`,单节点下报名请求会排队;多节点下该锁无法跨节点保证一致性。 +- JMeter 1300线程会消耗较多内存。若出现堆内存不足,可在启动前提高 `HEAP`,例如 `-Xms2g -Xmx4g`。 +- 不提供自动删除报名数据的脚本,避免误删真实报名记录。 diff --git a/jmeter/brand-activity-mountain-signup-1300.jmx b/jmeter/brand-activity-mountain-signup-1300.jmx new file mode 100644 index 0000000..e8f1b70 --- /dev/null +++ b/jmeter/brand-activity-mountain-signup-1300.jmx @@ -0,0 +1,291 @@ + + + + + 每个线程使用独立用户和 Cookie 登录,登录完成后在报名接口前集合并发。仅限本地或经授权的测试环境。 + false + true + false + + + + activityId + ${__P(activityId,11619eee67574256ad8ccc8a16630023)} + = + + + courseId + ${__P(courseId,986bf4d48a5948e1810857483f9e5af2)} + = + + + redisHost + ${__P(redisHost,127.0.0.1)} + = + + + redisPort + ${__P(redisPort,6378)} + = + + + testPassword + ${__P(testPassword,LoadTestOnly@123)} + = + + + + + + + , + UTF-8 + ${__P(usersFile,jmeter/data/brand-activity-users.csv)} + false + true + false + shareMode.all + true + + + + + continue + + false + 1 + + ${__P(threads,10)} + ${__P(loginRampSeconds,30)} + false + + + true + + + + + + + ${__P(host,127.0.0.1)} + ${__P(port,80)} + ${__P(protocol,http)} + ${__P(connectTimeout,10000)} + ${__P(responseTimeout,120000)} + UTF-8 + + + + + true + false + + + + + + Content-Type + application/x-www-form-urlencoded;charset=UTF-8 + + + Accept + application/json, text/plain, */* + + + + + + + + + /platform/login/captcha + POST + true + true + false + + + + captchaKey + $.data.key + 1 + NOT_FOUND + + + + + true + + + groovy + + + + + + + + /platform/login/publicKey + GET + true + true + + + + publicKey;keyId + $.data.publicKey;$.data.keyId + 1;1 + NOT_FOUND;NOT_FOUND + + + + + true + + + groovy + + + + + + + + trueusername${loginname}=true + + + truepassword${encryptedPassword}=true + + + trueplatformKey${captchaKey}=true + + + trueplatformCaptcha${captchaCode}=true + + + truekeyId${keyId}=true + + + + /platform/login/doLogin + POST + true + true + false + + + + groovy + + + + + + + + truecourseId${courseId}=true + truecurrentFamilyNumber0=true + + + /platform/trainSignUp/apply/validateSignUp + POST + true + true + false + + + + + + trueactivityId${activityId}=true + truecourseId${courseId}=true + truemobileColumnsValue[]=true + + + /platform/trainSignUp/apply/doSignUp + POST + true + true + false + + + + 0 + ${__P(syncTimeoutMs,180000)} + + + + groovy + + + + + + + + diff --git a/jmeter/prepare-brand-activity-users.ps1 b/jmeter/prepare-brand-activity-users.ps1 new file mode 100644 index 0000000..5e8dcc0 --- /dev/null +++ b/jmeter/prepare-brand-activity-users.ps1 @@ -0,0 +1,132 @@ +param( + [ValidateRange(1, 1300)] + [int]$UserCount = 1300, + + [ValidateSet("AllScope", "Authorized")] + [string]$PermissionMode = "AllScope", + + [ValidatePattern("^[0-9a-fA-F]{32}$")] + [string]$ActivityId = "11619eee67574256ad8ccc8a16630023", + + [string]$OutputFile = (Join-Path $PSScriptRoot "data\brand-activity-users.csv"), + + [string]$ApplicationConfig = (Join-Path $PSScriptRoot "..\src\main\resources\application.yaml") +) + +$ErrorActionPreference = "Stop" + +function Get-YamlScalar { + param( + [string[]]$Lines, + [string]$Name + ) + + $matchedLine = $Lines | Where-Object { $_ -match ("^\s*" + [regex]::Escape($Name) + "\s*:\s*(.+?)\s*$") } | Select-Object -First 1 + if (-not $matchedLine) { + throw "未在 application.yaml 中找到配置项:$Name" + } + return ([regex]::Match($matchedLine, "^\s*[^:]+:\s*(.+?)\s*$")).Groups[1].Value.Trim('"', "'") +} + +if (-not (Test-Path -LiteralPath $ApplicationConfig)) { + throw "找不到应用配置文件:$ApplicationConfig" +} + +$mysqlCommand = Get-Command mysql -ErrorAction SilentlyContinue +if (-not $mysqlCommand) { + throw "未找到 mysql 命令,请先把 MySQL 客户端 bin 目录加入 PATH。" +} + +$configLines = Get-Content -LiteralPath $ApplicationConfig -Encoding UTF8 +$jdbcUrl = Get-YamlScalar -Lines $configLines -Name "url" +$dbUser = Get-YamlScalar -Lines $configLines -Name "username" +$dbPassword = Get-YamlScalar -Lines $configLines -Name "password" + +$jdbcMatch = [regex]::Match($jdbcUrl, "jdbc:mysql://(?[^:/?]+)(:(?\d+))?/(?[^?]+)") +if (-not $jdbcMatch.Success) { + throw "无法解析 JDBC 地址:$jdbcUrl" +} + +$dbHost = $jdbcMatch.Groups["host"].Value +$dbPort = if ($jdbcMatch.Groups["port"].Success) { $jdbcMatch.Groups["port"].Value } else { "3306" } +$database = $jdbcMatch.Groups["database"].Value + +# 仅导出活动范围内、账号未禁用且尚未报名的人员;不读取密码、身份证号、手机号等敏感字段。 +$permissionCondition = if ($PermissionMode -eq "Authorized") { + @" +AND EXISTS ( + SELECT 1 + FROM sys_user_role ur + JOIN sys_role_menu rm ON rm.roleId = ur.roleId + JOIN sys_menu m ON m.id = rm.menuId + WHERE ur.userId = u.id + AND m.permission IN ('trainSignUp.apply', 'h5.trainSignUp.apply') +) +"@ +} else { + "" +} + +$query = @" +SELECT + u.loginname, + CASE WHEN EXISTS ( + SELECT 1 + FROM sys_user_role ur + JOIN sys_role_menu rm ON rm.roleId = ur.roleId + JOIN sys_menu m ON m.id = rm.menuId + WHERE ur.userId = u.id + AND m.permission IN ('trainSignUp.apply', 'h5.trainSignUp.apply') + ) THEN 1 ELSE 0 END AS expectedAuthorized +FROM train_sign_up_activity a +JOIN activity_user_scope aus ON aus.groupId = a.activityGroupId +JOIN sys_user u ON u.id = aus.userId +LEFT JOIN train_sign_up_user su ON su.activityId = a.id AND su.userId = u.id +WHERE a.id = '$ActivityId' + AND u.disabled = 0 + AND u.loginname IS NOT NULL + AND u.loginname <> '' + AND su.id IS NULL +$permissionCondition +GROUP BY u.id, u.loginname +ORDER BY expectedAuthorized DESC, u.loginname +LIMIT $UserCount; +"@ + +$previousMysqlPassword = $env:MYSQL_PWD +try { + $env:MYSQL_PWD = $dbPassword + $rows = @(& $mysqlCommand.Source --batch --raw --skip-column-names -h $dbHost -P $dbPort -u $dbUser $database -e $query) + if ($LASTEXITCODE -ne 0) { + throw "查询压测人员失败,mysql 退出码:$LASTEXITCODE" + } +} finally { + $env:MYSQL_PWD = $previousMysqlPassword +} + +$users = @($rows | Where-Object { $_ -and $_.Trim() } | ForEach-Object { + $columns = $_ -split "`t", 2 + [pscustomobject]@{ + loginname = $columns[0] + expectedAuthorized = $columns[1] + } +}) + +if ($users.Count -lt $UserCount) { + throw "符合条件的人员只有 $($users.Count) 人,少于要求的 $UserCount 人。PermissionMode=$PermissionMode" +} + +$outputDirectory = Split-Path -Parent $OutputFile +if (-not (Test-Path -LiteralPath $outputDirectory)) { + New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null +} + +$users | Export-Csv -LiteralPath $OutputFile -NoTypeInformation -Encoding UTF8 -UseQuotes Always + +$authorizedCount = @($users | Where-Object { $_.expectedAuthorized -eq "1" }).Count +$unauthorizedCount = $users.Count - $authorizedCount +Write-Host "已生成 UTF-8 用户文件:$OutputFile" +Write-Host "总人数:$($users.Count),具备报名权限:$authorizedCount,无报名权限:$unauthorizedCount" +if ($unauthorizedCount -gt 0) { + Write-Warning "无报名权限的线程仍会访问报名接口,但会被权限校验拒绝。" +} diff --git a/src/main/java/com/budwk/app/base/constant/RedisConstant.java b/src/main/java/com/budwk/app/base/constant/RedisConstant.java index 6920246..fb823f8 100644 --- a/src/main/java/com/budwk/app/base/constant/RedisConstant.java +++ b/src/main/java/com/budwk/app/base/constant/RedisConstant.java @@ -20,6 +20,9 @@ public class RedisConstant { //企业微信token public final static String REDIS_KEY_WX_WORK_TOKEN = PLATFORM_REDIS_PREFIX + "wxwork:token:"; + // 学校消息中心钉钉提醒 Token;按 appId、compId 隔离,刷新锁与 Token 使用同一哈希槽。 + public static final String REDIS_KEY_DING_MESSAGE_TOKEN = PLATFORM_REDIS_PREFIX + "ding:message:token:"; + //健步走小程序TOKEN public final static String REDIS_KEY_WE_APP_ACCESS_TOKEN = PLATFORM_REDIS_PREFIX + "weapp:token:"; diff --git a/src/main/java/com/budwk/app/base/sms/SmsService.java b/src/main/java/com/budwk/app/base/sms/SmsService.java index 4281afe..030f241 100644 --- a/src/main/java/com/budwk/app/base/sms/SmsService.java +++ b/src/main/java/com/budwk/app/base/sms/SmsService.java @@ -136,4 +136,7 @@ public interface SmsService { * @return */ void recallMsg(String msgId); + + /** 后台群发卡片:工号列表、标题、正文、PC/H5 跳转地址及提交人的工号;无返回值。 */ + void massSendTextCard(List loginNames, String title, String content, String pcUrl, String mobileUrl, String fromUser); } diff --git a/src/main/java/com/budwk/app/base/sms/impl/SmsServiceImpl.java b/src/main/java/com/budwk/app/base/sms/impl/SmsServiceImpl.java new file mode 100644 index 0000000..8f217be --- /dev/null +++ b/src/main/java/com/budwk/app/base/sms/impl/SmsServiceImpl.java @@ -0,0 +1,321 @@ +package com.budwk.app.base.sms.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.budwk.app.base.constant.RedisConstant; +import com.budwk.app.base.sms.SmsService; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.web.commons.base.Globals; +import lombok.extern.slf4j.Slf4j; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.integration.jedis.RedisService; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +/** + * @ClassName SmsServiceImpl + * @Author JyuHsin + * @Date 2025/11/26 19:32 + * @Version 1.0 + * 通过学校消息中心发送钉钉提醒,正文填充钉钉模板变量 #{content}。 + */ +@Slf4j +@IocBean +public class SmsServiceImpl implements SmsService { + + private static final String BASE_URL = "https://ua.zjipc.cn/messageng/openApi"; + // 由信息中心提供,templateId 必须对应包含 #{content} 的钉钉模板。 + private static final String APP_ID = "xy6t2ht3qkkh6jdx9cyhwmg4bhgldjn3"; + private static final String APP_SECRET = "T_AtDFHS0KHVCvpZK5zpx6bnRuIDNcwu4BgFLOZxXi0w45HrTIto1q_UYmX8lnjf"; + private static final String COMP_ID = "30c2b301a1759c0ebcb4550635066ac0"; + private static final String TEMPLATE_ID = "DING_083"; + // 异步任务无登录上下文,按文档示例使用系统发送者。 + private static final int REQUEST_TIMEOUT = 5000; + // 锁覆盖认证超时窗口,进程退出后自动释放;写缓存时再次校验锁归属。 + private static final int TOKEN_LOCK_SECONDS = 30; + private static final String DELETE_IF_MATCH = "if redis.call('get', KEYS[1]) == ARGV[1] then " + + "return redis.call('del', KEYS[1]) else return 0 end"; + private static final String CACHE_IF_OWNER = "if redis.call('get', KEYS[1]) == ARGV[1] then " + + "redis.call('setex', KEYS[2], ARGV[2], ARGV[3]); return 1 else return 0 end"; + + @Inject + private RedisService redisService; + + /** 按工号单发正文,使用默认标题和首页链接,无返回值。 */ + @Override + public void sendSingleMessage(String loginName, String content) { + this.sendSingleMessage(loginName, "智慧工会", content); + } + + /** 按工号单发指定标题与正文,无返回值,链接默认使用首页。 */ + @Override + public void sendSingleMessage(String loginName, String title, String content) { + this.massSendMessage(Collections.singletonList(loginName), title, content); + } + + /** 按工号发送正文与 PC/H5 业务链接,使用默认标题,无返回值。 */ + @Override + public void sendSingleTextCard(String loginName, String content, String pcUrl, String mobileUrl) { + this.sendSingleTextCard(loginName, "智慧工会", content, pcUrl, mobileUrl); + } + + /** 按工号发送指定标题、正文与 PC/H5 业务链接,无返回值。 */ + @Override + public void sendSingleTextCard(String loginName, String title, String content, String pcUrl, String mobileUrl) { + this.massSendTextCard(Collections.singletonList(loginName), title, content, pcUrl, mobileUrl); + } + + /** 兼容原图文单发参数,使用默认标题,图片限制由群发入口统一处理,无返回值。 */ + @Override + public void sendSinglePicTextCard(String loginName, String content, String pcUrl, String mobileUrl, String picUrl) { + this.sendSinglePicTextCard(loginName, "智慧工会", content, pcUrl, mobileUrl, picUrl); + } + + /** 按工号接收指定标题的原图文参数,仅发送正文与业务链接,无返回值。 */ + @Override + public void sendSinglePicTextCard(String loginName, String title, String content, String pcUrl, String mobileUrl, String picUrl) { + this.massSendPicTextCard(Collections.singletonList(loginName), title, content, pcUrl, mobileUrl, picUrl); + } + + /** 向工号列表发送相同正文,默认标题和首页链接,无返回值。 */ + @Override + public void massSendMessage(List loginNames, String content) { + this.massSendMessage(loginNames, "智慧工会", content); + } + + /** 向工号列表发送指定标题和正文,默认使用首页链接,无返回值。 */ + @Override + public void massSendMessage(List loginNames, String title, String content) { + // 普通提醒也必须携带链接,未提供业务链接时使用系统首页。 + this.doSend(loginNames, title, content, null, null); + } + + /** 向工号列表发送正文与 PC/H5 链接,使用默认标题,无返回值。 */ + @Override + public void massSendTextCard(List loginNames, String content, String pcUrl, String mobileUrl) { + this.massSendTextCard(loginNames, "智慧工会", content, pcUrl, mobileUrl); + } + + /** 向工号列表发送指定标题的卡片提醒,优先 H5 链接,无返回值。 */ + @Override + public void massSendTextCard(List loginNames, String title, String content, String pcUrl, String mobileUrl) { + this.doSend(loginNames, title, content, pcUrl, mobileUrl); + } + + /** 后台发送使用提交时取得的工号,不依赖工作线程的登录上下文;参数及返回约定见接口。 */ + @Override + public void massSendTextCard(List loginNames, String title, String content, String pcUrl, String mobileUrl, String fromUser) { + this.doSend(loginNames, title, content, pcUrl, mobileUrl, fromUser); + } + + /** 兼容原图文群发参数,使用默认标题,无返回值。 */ + @Override + public void massSendPicTextCard(List loginNames, String content, String pcUrl, String mobileUrl, String picUrl) { + this.massSendPicTextCard(loginNames, "智慧工会", content, pcUrl, mobileUrl, picUrl); + } + + /** 接收工号列表、标题、正文、PC/H5 链接和图片 URL;图片不发送,无返回值。 */ + @Override + public void massSendPicTextCard(List loginNames, String title, String content, String pcUrl, String mobileUrl, String picUrl) { + // 文档无图片 URL 参数,保留调用入口但按普通卡片发送,不能将图片误填为附件 ID。 + if (StrUtil.isNotBlank(picUrl)) { + log.warn("钉钉消息中心暂不支持图片 URL,本次仅发送正文与链接"); + } + this.doSend(loginNames, title, content, pcUrl, mobileUrl); + } + + /** 接收待撤回消息 ID;当前文档无撤回能力,仅记录未执行,无返回值。 */ + @Override + public void recallMsg(String msgId) { + // 文档未提供撤回协议,保留接口兼容调用方,不再请求旧企业微信平台。 + log.warn("钉钉消息中心文档未提供撤回接口,未执行撤回"); + } + + /** + * 接收工号列表、标题、正文和 PC/H5 链接;按文档逐人发送,无返回值。 + * 正文映射为模板变量 content,单人失败记录后继续其余接收人,沿用原服务行为。 + */ + private void doSend(List loginNames, String title, String content, String pcUrl, String mobileUrl) { + this.doSend(loginNames, title, content, pcUrl, mobileUrl, null); + } + + /** fromUser 为后台任务传入的发送人工号;为空时沿用当前请求身份。发送结果写入日志。 */ + private void doSend(List loginNames, String title, String content, String pcUrl, String mobileUrl, String fromUser) { + if (!Globals.MyConfig.getBoolean("AppSms", false) || !Globals.sso) { + log.info("钉钉消息提醒未发送:消息开关未开启或当前为开发模式"); + return; + } + if (StrUtil.hasBlank(APP_ID, APP_SECRET, COMP_ID, TEMPLATE_ID)) { + log.warn("钉钉消息提醒未发送:请补充 appId、appSecret、compId、templateId"); + return; + } + if (loginNames == null || loginNames.isEmpty() || StrUtil.isBlank(content)) { + log.warn("钉钉消息提醒未发送:接收人或正文为空"); + return; + } + List receivers = loginNames.stream().filter(StrUtil::isNotBlank) + .map(String::trim).distinct().toList(); + if (receivers.isEmpty()) { + return; + } + final String linkUrl; + final String token; + try { + // 协议仅接受一个链接;优先移动端,其次 PC,普通消息使用系统首页。 + String target = StrUtil.blankToDefault(mobileUrl, StrUtil.blankToDefault(pcUrl, Globals.AppDomain)); + URI uri = URI.create(target); + URI resolved = uri.isAbsolute() ? uri : URI.create(Globals.AppDomain + "/").resolve(uri); + if (!("https".equalsIgnoreCase(resolved.getScheme()) || "http".equalsIgnoreCase(resolved.getScheme())) + || StrUtil.isBlank(resolved.getHost())) { + throw new IllegalArgumentException("钉钉提醒链接必须为 HTTP/HTTPS 地址"); + } + linkUrl = resolved.toString(); + token = getAccessToken(); + } catch (Exception e) { + // HTTP 异常可能带有含凭证的 URL,不输出异常正文或 Token。 + log.error("钉钉消息提醒准备失败,异常类型:{}", e.getClass().getSimpleName()); + return; + } + for (String userId : receivers) { + String messageId = UUID.randomUUID().toString().replace("-", ""); + try { + // 外层 content 必须为 JSON 字符串,内层 content 对应模板中的 #{content}。 + JSONObject params = new JSONObject(); + params.set("id", messageId); + params.set("templateId", TEMPLATE_ID); + params.set("title", StrUtil.blankToDefault(title, "智慧工会")); + params.set("fromUser", StrUtil.isNotBlank(fromUser) ? fromUser : SecurityUtil.getUserLoginname()); + params.set("userId", userId); + params.set("content", new JSONObject().set("content", content).toString()); + params.set("detailInfo", new JSONObject().set("linkUrl", linkUrl).set("urlDes", "查看详情")); + log.info("钉钉消息提醒参数打印,detailInfo={}", params.toString()); + JSONObject result = sendMessage(token, params); + log.info("钉钉消息提醒请求结果返回,result={}", result.toString()); + String code = result.getStr("code"); + // 凭证失效清理缓存并停止批次,下次发送重新获取;网络异常不重发,避免重复提醒。 + if ("10002".equals(code)) { + invalidateToken(token); + log.error("钉钉消息提醒认证失效,msgId={},停止本批次发送", messageId); + break; + } + if (Boolean.TRUE.equals(result.getBool("status")) && ("1".equals(code) || "5".equals(code))) { + log.info("钉钉消息提醒发送成功,msgId={},code={}", messageId, code); + } else { + log.error("钉钉消息提醒发送未成功,msgId={},code={}", messageId, code); + } + } catch (Exception e) { + log.error("钉钉消息提醒发送异常,msgId={},异常类型:{}", messageId, e.getClass().getSimpleName()); + } + } + } + + /** + * 返回认证响应 result.access_token;result.expires_in 为有效秒数。 + * 优先读取 Redis,多实例缓存未命中时仅允许持锁者申请,其余请求限时等待缓存。 + */ + private String getAccessToken() { + String key = tokenCacheKey(); + String lockKey = key + ":refresh"; + String owner = UUID.randomUUID().toString(); + long deadline = System.nanoTime() + REQUEST_TIMEOUT * 2L * 1_000_000L; + while (true) { + String cached = redisService.get(key); + if (StrUtil.isNotBlank(cached)) { + return cached; + } + if ("OK".equals(redisService.set(lockKey, owner, "NX", "EX", TOKEN_LOCK_SECONDS))) { + try { + // 取锁前其他实例可能刚写入缓存,二次读取避免重复认证。 + cached = redisService.get(key); + return StrUtil.isNotBlank(cached) ? cached : requestAndCacheToken(key, lockKey, owner); + } finally { + // 仅释放自己的锁;Redis 异常不覆盖认证结果,锁仍会按 TTL 自动过期。 + try { + redisService.eval(DELETE_IF_MATCH, List.of(lockKey), List.of(owner)); + } catch (Exception e) { + log.warn("钉钉 Token 刷新锁释放失败,将自动过期,异常类型:{}", e.getClass().getSimpleName()); + } + } + } + if (System.nanoTime() >= deadline) { + throw new IllegalStateException("等待钉钉 Token 刷新超时"); + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("等待钉钉 Token 刷新被中断"); + } + } + } + + /** + * key 为 Token 缓存键,lockKey/owner 为当前刷新锁及归属标识,返回可发送消息的 Token。 + * TTL 按 expires_in 扣除请求耗时及提前过期窗口计算,失败或锁过期时不写入缓存。 + */ + private String requestAndCacheToken(String key, String lockKey, String owner) { + long requestedAt = System.nanoTime(); + try (HttpResponse response = HttpRequest.get(BASE_URL + "/token") + .form("appId", APP_ID).form("appSecret", APP_SECRET).form("compId", COMP_ID) + .timeout(REQUEST_TIMEOUT).execute()) { + if (!response.isOk()) { + throw new IllegalStateException("消息中心认证 HTTP 状态异常"); + } + JSONObject result = JSONUtil.parseObj(response.body()); + JSONObject data = result.getJSONObject("result"); + if (!Boolean.TRUE.equals(result.getBool("success")) || !"0".equals(result.getStr("code")) || data == null) { + throw new IllegalStateException("消息中心认证失败"); + } + String value = data.getStr("access_token"); + long expiresIn = data.getLong("expires_in", 0L); + if (StrUtil.isBlank(value) || expiresIn <= 0) { + throw new IllegalStateException("消息中心认证凭证或有效期为空"); + } + long elapsedSeconds = (System.nanoTime() - requestedAt) / 1_000_000_000L + 1; + long ttl = expiresIn - Math.min(60, expiresIn / 10) - elapsedSeconds; + if (ttl <= 0 || ttl > Integer.MAX_VALUE) { + throw new IllegalStateException("消息中心 Token 缓存有效期不合法"); + } + Object saved = redisService.eval(CACHE_IF_OWNER, List.of(lockKey, key), + List.of(owner, String.valueOf(ttl), value)); + if (!Long.valueOf(1).equals(saved)) { + throw new IllegalStateException("消息中心 Token 刷新锁已失效"); + } + return value; + } + } + + /** token 为接口报告失效的旧凭证;Lua 原子比较删除,避免误删其他实例刷新的凭证。 */ + private void invalidateToken(String token) { + redisService.eval(DELETE_IF_MATCH, List.of(tokenCacheKey()), List.of(token)); + } + + /** 按应用与单位隔离凭证;花括号确保 Token 和刷新锁在 Redis Cluster 中属于同一槽。 */ + private String tokenCacheKey() { + return RedisConstant.REDIS_KEY_DING_MESSAGE_TOKEN + "{" + APP_ID + ":" + COMP_ID + "}"; + } + + /** + * token 为 URL 查询凭证,params 为单人消息 JSON; + * 返回含 status、code、message、info 的响应对象,调用方继续判断业务发送结果。 + */ + private JSONObject sendMessage(String token, JSONObject params) { + String url = BASE_URL + "/message/send?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8); + try (HttpResponse response = HttpRequest.post(url).header("Content-Type", "application/json; charset=UTF-8").body(params.toString()) + .timeout(REQUEST_TIMEOUT).execute()) { + if (!response.isOk()) { + throw new IllegalStateException("消息中心发送 HTTP 状态异常"); + } + return JSONUtil.parseObj(response.body()); + } + } +} diff --git a/src/main/java/com/budwk/app/base/sms/impl/jshvc/SmsJshvcServiceImpl.java b/src/main/java/com/budwk/app/base/sms/impl/jshvc/SmsJshvcServiceImpl.java deleted file mode 100644 index b973050..0000000 --- a/src/main/java/com/budwk/app/base/sms/impl/jshvc/SmsJshvcServiceImpl.java +++ /dev/null @@ -1,182 +0,0 @@ -package com.budwk.app.base.sms.impl.jshvc; - -import com.budwk.app.base.sms.SmsService; -import com.budwk.app.todo.platform.TodoPlatformConfig; -import com.budwk.app.web.commons.base.Globals; -import com.wisedu.casp.sdk.api.mc.ReceiverDto; -import com.wisedu.casp.sdk.api.mc.SendMsgRequest; -import com.wisedu.casp.sdk.api.mc.SendMsgResponse; -import com.wisedu.casp.sdk.api.mc.constants.MsgType_Constants; -import com.wisedu.casp.sdk.api.mc.constants.SendType_Constants; -import com.wisedu.casp.sdk.api.mc.constants.WxSendType_Constants; -import com.wisedu.casp.sdk.api.mc.recallMsg.RecallMsgRequest; -import com.wisedu.casp.sdk.api.mc.recallMsg.RecallMsgResponse; -import com.wisedu.casp.sdk.core.Client; -import com.wisedu.casp.sdk.core.DefaultClient; -import lombok.extern.slf4j.Slf4j; -import org.nutz.ioc.loader.annotation.IocBean; -import org.nutz.json.Json; -import org.nutz.lang.Lang; - -import java.util.List; - -/** - * @ClassName SmsJshvcServiceImpl - * @Author JyuHsin - * @Date 2025/11/26 19:32 - * @Version 1.0 - * @Description TODO - */ -@Slf4j -@IocBean -public class SmsJshvcServiceImpl implements SmsService { - - @Override - public void sendSingleMessage(String loginName, String content) { - this.sendSingleMessage(loginName, "智慧工会", content); - } - - @Override - public void sendSingleMessage(String loginName, String title, String content) { - this.massSendMessage(List.of(loginName), title, content); - } - - @Override - public void sendSingleTextCard(String loginName, String content, String pcUrl, String mobileUrl) { - this.sendSingleTextCard(loginName, "智慧工会", content, pcUrl, mobileUrl); - } - - @Override - public void sendSingleTextCard(String loginName, String title, String content, String pcUrl, String mobileUrl) { - this.massSendTextCard(List.of(loginName), title, content, pcUrl, mobileUrl); - } - - @Override - public void sendSinglePicTextCard(String loginName, String content, String pcUrl, String mobileUrl, String picUrl) { - this.sendSinglePicTextCard(loginName, "智慧工会", content, pcUrl, mobileUrl, picUrl); - } - - @Override - public void sendSinglePicTextCard(String loginName, String title, String content, String pcUrl, String mobileUrl, String picUrl) { - this.massSendPicTextCard(List.of(loginName), title, content, pcUrl, mobileUrl, picUrl); - } - - @Override - public void massSendMessage(List loginNames, String content) { - this.massSendMessage(loginNames, "智慧工会", content); - } - - @Override - public void massSendMessage(List loginNames, String title, String content) { - List list = loginNames.stream().map(loginName -> { - ReceiverDto receiverDto = new ReceiverDto(); - receiverDto.setUserId(loginName); - return receiverDto; - }).toList(); - this.doSend(WxSendType_Constants.TEXT, list, title, content, null, null, null); - } - - @Override - public void massSendTextCard(List loginNames, String content, String pcUrl, String mobileUrl) { - this.massSendTextCard(loginNames, "智慧工会", content, pcUrl, mobileUrl); - } - - @Override - public void massSendTextCard(List loginNames, String title, String content, String pcUrl, String mobileUrl) { - List list = loginNames.stream().map(loginName -> { - ReceiverDto receiverDto = new ReceiverDto(); - receiverDto.setUserId(loginName); - return receiverDto; - }).toList(); - this.doSend(WxSendType_Constants.TEXT_CARD, list, title, content, pcUrl, mobileUrl, null); - } - - @Override - public void massSendPicTextCard(List loginNames, String content, String pcUrl, String mobileUrl, String picUrl) { - this.massSendPicTextCard(loginNames, "智慧工会", content, pcUrl, mobileUrl, picUrl); - } - - @Override - public void massSendPicTextCard(List loginNames, String title, String content, String pcUrl, String mobileUrl, String picUrl) { - List list = loginNames.stream().map(loginName -> { - ReceiverDto receiverDto = new ReceiverDto(); - receiverDto.setUserId(loginName); - return receiverDto; - }).toList(); - this.doSend(WxSendType_Constants.NEWS, list, title, content, pcUrl, mobileUrl, picUrl); - } - - @Override - public void recallMsg(String msgId) { - try { - Client client = new DefaultClient(TodoPlatformConfig.TODO_PLATFORM_BASE_URL, TodoPlatformConfig.TODO_APP_ID, TodoPlatformConfig.TODO_APP_SECRET); - RecallMsgRequest request = new RecallMsgRequest(); - request.setMsgId(msgId); - - log.info("撤回企业微信消息,请求参数:{}", Json.toJson(request)); - RecallMsgResponse response = client.execute(request); - log.info("撤回企业微信消息,接口响应数据:{}", Json.toJson(response)); - } catch (Exception e) { - log.error("撤回企业微信消息失败", e); - } - } - - private void doSend(String sendType, - List receivers, - String title, - String content, - String pcUrl, - String mobileUrl, - String picUrl) { - if (!Globals.MyConfig.getBoolean("AppSms", false)) { - log.error("【log】消息配置未开启"); - return; - } - if (!Globals.sso) { - log.error("【log】开发模式不允许发送企业微信通知"); - return; - } - - if (Lang.isEmpty(receivers)) { - log.error("send fail by receivers is null"); - return; - } - try { - // 创建客户端 - Client client = new DefaultClient(TodoPlatformConfig.TODO_PLATFORM_BASE_URL, TodoPlatformConfig.TODO_APP_ID, TodoPlatformConfig.TODO_APP_SECRET); - SendMsgRequest request = new SendMsgRequest(); - request.setSubject(title); - request.setContent(content); - request.setMsgType(MsgType_Constants.COMMON); - request.setSendType(SendType_Constants.WECHAT_ENTERPRISE); - request.setWxSendType(sendType); - request.setReceivers(receivers); - - if (WxSendType_Constants.TEXT_CARD.equals(sendType)) { - request.setPcUrl(pcUrl); - request.setMobileUrl(mobileUrl); - request.setUrlDesc("查看详情"); - } - if (WxSendType_Constants.NEWS.equals(sendType)) { - request.setPcUrl(pcUrl); - request.setMobileUrl(mobileUrl); - request.setUrlDesc("查看详情"); - request.setQyWeChatImgUrl(picUrl); - } - - log.info("发送企业微信通知,请求参数:{}", Json.toJson(request)); - SendMsgResponse response = client.execute(request); - log.info("发送企业微信通知,接口响应数据:{}", Json.toJson(response)); - - if (response.isSuccess() && response.getStatus() == 200) { - // TODO 消息Id,后面可以存着,撤回要用到 - String msgId = response.getMsgId(); - log.info("发送企业微信通知成功: {}, msg: {}", response.getStatus(), response.getMsg()); - } else { - log.info("发送企业微信通知失败:{}", response.getMsg()); - } - } catch (Exception e) { - log.error("发送企业微信通知失败", e); - } - } -} diff --git a/src/main/java/com/budwk/app/sys/controller/SysFileController.java b/src/main/java/com/budwk/app/sys/controller/SysFileController.java index 8cc9c89..ca9f54c 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysFileController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysFileController.java @@ -24,7 +24,9 @@ import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.io.RandomAccessRead; import org.apache.pdfbox.io.RandomAccessReadBuffer; import org.apache.pdfbox.pdmodel.PDDocument; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.mvc.annotation.*; @@ -143,6 +145,43 @@ public class SysFileController { return Result.success().addData(sysFileService.convertHtml(file)); } + /** + * 将PDF逐页转换为图片富文本。 + * + * @param file 上传的PDF文件 + * @return Result,data为按PDF页码顺序组成的图片HTML + */ + @At + @SaCheckLogin + @ApiOperation("文件管理-PDF转换图片富文本") + @AdaptBy(type = UploadAdaptor.class, args = {"ioc:fileUpload"}) + @Aop(TransAop.READ_COMMITTED) + public Result convertPdfToImageHtml(TempFile file) { + if (file == null || !"pdf".equalsIgnoreCase(FileUtil.extName(file.getSubmittedFileName()))) { + return Result.error("请上传PDF文件"); + } + return Result.success().addData(sysFileService.convertPdfToImageHtml(file)); + } + + /** + * 将Excel的每个非空页签转换为独立HTML表格。 + * + * @param file 上传的xls或xlsx文件 + * @return Result,data为按页签顺序组成的标题及HTML表格 + */ + @At + @SaCheckLogin + @ApiOperation("文件管理-Excel转换表格富文本") + @AdaptBy(type = UploadAdaptor.class, args = {"ioc:fileUpload"}) + @Aop(TransAop.READ_COMMITTED) + public Result convertExcelToHtml(TempFile file) { + String extension = file == null ? "" : FileUtil.extName(file.getSubmittedFileName()); + if (!"xls".equalsIgnoreCase(extension) && !"xlsx".equalsIgnoreCase(extension)) { + return Result.error("请上传xls或xlsx文件"); + } + return Result.success().addData(sysFileService.convertExcelToHtml(file)); + } + @At @ApiOperation("文件管理-删除文件") diff --git a/src/main/java/com/budwk/app/sys/models/Sys_user.java b/src/main/java/com/budwk/app/sys/models/Sys_user.java index e7f9762..b4e83be 100644 --- a/src/main/java/com/budwk/app/sys/models/Sys_user.java +++ b/src/main/java/com/budwk/app/sys/models/Sys_user.java @@ -386,4 +386,16 @@ public class Sys_user extends BaseModel implements Serializable { @ColDefine(type = ColType.VARCHAR, width = 50) @DataCenterColumn(name = "党政职务", key = "DZZW") private String position; + + @Column + @Comment("人员类别码") + @ColDefine(type = ColType.VARCHAR, width = 50) + @DataCenterColumn(name = "人员类别码", key = "rylbm") + private String rylbm; + + @Column + @Comment("在岗情况码") + @ColDefine(type = ColType.VARCHAR, width = 50) + @DataCenterColumn(name = "在岗情况码", key = "zgqkm") + private String zgqkm; } diff --git a/src/main/java/com/budwk/app/sys/services/SysFileService.java b/src/main/java/com/budwk/app/sys/services/SysFileService.java index 9976d46..8974138 100644 --- a/src/main/java/com/budwk/app/sys/services/SysFileService.java +++ b/src/main/java/com/budwk/app/sys/services/SysFileService.java @@ -71,4 +71,20 @@ public interface SysFileService extends BaseService { * 转换为HTML */ String convertHtml(TempFile file); + + /** + * 将PDF逐页转换为图片并生成富文本HTML。 + * + * @param file PDF文件,文件内容不能为空 + * @return 按PDF页码顺序排列的图片HTML + */ + String convertPdfToImageHtml(TempFile file); + + /** + * 将Excel中的非空页签转换为富文本HTML表格。 + * + * @param file xls或xlsx文件,文件内容不能为空 + * @return 按页签顺序排列的标题及HTML表格 + */ + String convertExcelToHtml(TempFile file); } diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysDataUserAllUpdateServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysDataUserAllUpdateServiceImpl.java index f018e82..bde70e9 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysDataUserAllUpdateServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysDataUserAllUpdateServiceImpl.java @@ -23,6 +23,7 @@ import com.budwk.app.zhgh.democratic.proposal.models.ProposalConfig; import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeOrigin; import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeType; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Chain; import org.nutz.dao.Cnd; @@ -182,6 +183,7 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService List removeMemberUserIds = Collections.synchronizedList(new ArrayList<>()); List preparedByList = List.of("人才市场派遣", "人力资源服务中心派遣", "海川派遣", "越仕中心", "柔性引进"); + List rylbmList = List.of("退休返聘", "临聘"); // 处理每条数据 for (Sys_user_source source : sources) { @@ -197,7 +199,9 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService u.setSalt(salt); u.setPassword(PwdUtil.getPassword(PwdUtil.generate(12), salt)); - if (u.getUserState().equals("在职") && (u.getPersonType().equals("在编") || preparedByList.contains(u.getPreparedBy()))) { + if (u.getUserState().equals("在职") + && (u.getPersonType().equals("在编") || preparedByList.contains(u.getPreparedBy())) + && (StringUtils.isBlank(u.getRylbm()) || !rylbmList.contains(u.getRylbm()))) { addMemberUserIds.add(u.getId()); u.setMember(true); } @@ -208,7 +212,9 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService u.setMember(null); u.setMemberTime(null); // 更新会员状态 - if (u.getUserState().equals("在职") && (u.getPersonType().equals("在编") || preparedByList.contains(u.getPreparedBy()))) { + if (u.getUserState().equals("在职") + && (u.getPersonType().equals("在编") || preparedByList.contains(u.getPreparedBy())) + && (StringUtils.isBlank(u.getRylbm()) || !rylbmList.contains(u.getRylbm()))) { if (!user.getMember()) { addMemberUserIds.add(u.getId()); u.setMember(true); diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysFileServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysFileServiceImpl.java index 92f35e8..733d1dd 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysFileServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysFileServiceImpl.java @@ -23,16 +23,53 @@ import com.budwk.app.sys.services.SysFileService; import com.budwk.app.sys.utils.SysFileLocalUtil; import com.budwk.app.sys.utils.SysFileMinIoUtil; import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.apache.poi.hssf.usermodel.HSSFClientAnchor; +import org.apache.poi.hssf.usermodel.HSSFPatriarch; +import org.apache.poi.hssf.usermodel.HSSFPicture; +import org.apache.poi.hssf.usermodel.HSSFShape; +import org.apache.poi.hssf.usermodel.HSSFSheet; +import org.apache.poi.hssf.util.HSSFColor; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.FormulaEvaluator; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.PictureData; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFClientAnchor; +import org.apache.poi.xssf.usermodel.XSSFDrawing; +import org.apache.poi.xssf.usermodel.XSSFPicture; +import org.apache.poi.xssf.usermodel.XSSFShape; +import org.apache.poi.xssf.usermodel.XSSFSheet; +import org.nutz.aop.interceptor.ioc.TransAop; import org.jsoup.Jsoup; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.json.Json; +import org.nutz.lang.Strings; import org.nutz.lang.random.R; import org.nutz.mvc.upload.TempFile; +import javax.imageio.ImageIO; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; @@ -41,7 +78,12 @@ import java.math.RoundingMode; import java.nio.file.Files; import java.nio.file.StandardOpenOption; import java.util.Base64; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -199,6 +241,370 @@ public class SysFileServiceImpl extends BaseServiceImpl implements Sys } } + @Override + @Aop(TransAop.READ_COMMITTED) + public String convertPdfToImageHtml(TempFile file) { + validateFileExtension(file, List.of("pdf"), "请上传PDF文件"); + try (PDDocument document = Loader.loadPDF(file.getFile())) { + validatePdfSafety(document); + if (document.getNumberOfPages() < 1) { + throw new BaseException("PDF文件中没有可导入的页面"); + } + + PDFRenderer renderer = new PDFRenderer(document); + StringBuilder html = new StringBuilder(); + for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) { + // 144 DPI兼顾文字清晰度和图片体积,页面顺序与原PDF保持一致。 + BufferedImage pageImage = renderer.renderImageWithDPI(pageIndex, 144, ImageType.RGB); + try (ByteArrayOutputStream imageOutput = new ByteArrayOutputStream()) { + ImageIO.write(pageImage, "png", imageOutput); + String imageUrl = storageFile(SysFileEngineTypeEnum.MINIO.getValue(), + R.UU32() + ".png", imageOutput.toByteArray(), false); + html.append("

") + .append("") + .append("

"); + } finally { + pageImage.flush(); + } + } + return html.toString(); + } catch (BaseException e) { + throw e; + } catch (Exception e) { + log.error("PDF转换图片异常", e); + throw new BaseException("PDF文件读取失败,请确认文件未损坏"); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public String convertExcelToHtml(TempFile file) { + validateFileExtension(file, List.of("xls", "xlsx"), "请上传xls或xlsx文件"); + try (Workbook workbook = WorkbookFactory.create(file.getInputStream())) { + DataFormatter formatter = new DataFormatter(); + FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator(); + StringBuilder html = new StringBuilder(); + int tableCount = 0; + + for (int sheetIndex = 0; sheetIndex < workbook.getNumberOfSheets(); sheetIndex++) { + Sheet sheet = workbook.getSheetAt(sheetIndex); + String tableHtml = buildSheetHtml(workbook, sheet, formatter, evaluator); + if (StrUtil.isBlank(tableHtml)) { + continue; + } + tableCount++; + html.append("
") + .append("

") + .append(Strings.escapeHtml(sheet.getSheetName())) + .append("

") + .append("
") + .append(tableHtml) + .append("
"); + } + + if (tableCount < 1) { + throw new BaseException("Excel文件中没有可导入的表格内容"); + } + return html.toString(); + } catch (BaseException e) { + throw e; + } catch (Exception e) { + log.error("Excel转换HTML异常", e); + throw new BaseException("Excel文件读取失败,请确认文件格式正确且未损坏"); + } + } + + /** + * 将单个非空页签转换为HTML表格,合并单元格使用rowspan和colspan还原。 + */ + private String buildSheetHtml(Workbook workbook, Sheet sheet, DataFormatter formatter, + FormulaEvaluator evaluator) { + List embeddedImages = extractSheetImages(sheet); + int minRow = Integer.MAX_VALUE; + int maxRow = -1; + int minColumn = Integer.MAX_VALUE; + int maxColumn = -1; + + for (Row row : sheet) { + for (Cell cell : row) { + if (StrUtil.isBlank(getCellDisplayValue(cell, formatter, evaluator))) { + continue; + } + minRow = Math.min(minRow, cell.getRowIndex()); + maxRow = Math.max(maxRow, cell.getRowIndex()); + minColumn = Math.min(minColumn, cell.getColumnIndex()); + maxColumn = Math.max(maxColumn, cell.getColumnIndex()); + } + } + // Excel图片存放在绘图层,即使锚定单元格没有文字,也必须纳入表格范围。 + for (ExcelEmbeddedImage image : embeddedImages) { + minRow = Math.min(minRow, image.rowIndex); + maxRow = Math.max(maxRow, image.rowIndex); + minColumn = Math.min(minColumn, image.columnIndex); + maxColumn = Math.max(maxColumn, image.columnIndex); + } + if (maxRow < 0 || maxColumn < 0) { + return ""; + } + + // 内容位于合并区域时,将区域边界纳入表格范围,避免合并内容被截断。 + for (CellRangeAddress range : sheet.getMergedRegions()) { + if (range.getFirstRow() <= maxRow && range.getLastRow() >= minRow + && range.getFirstColumn() <= maxColumn && range.getLastColumn() >= minColumn) { + minRow = Math.min(minRow, range.getFirstRow()); + maxRow = Math.max(maxRow, range.getLastRow()); + minColumn = Math.min(minColumn, range.getFirstColumn()); + maxColumn = Math.max(maxColumn, range.getLastColumn()); + } + } + + Map mergedStarts = new HashMap<>(); + Set mergedCovered = new HashSet<>(); + for (CellRangeAddress range : sheet.getMergedRegions()) { + if (range.getLastRow() < minRow || range.getFirstRow() > maxRow + || range.getLastColumn() < minColumn || range.getFirstColumn() > maxColumn) { + continue; + } + mergedStarts.put(cellKey(range.getFirstRow(), range.getFirstColumn()), range); + for (int rowIndex = range.getFirstRow(); rowIndex <= range.getLastRow(); rowIndex++) { + for (int columnIndex = range.getFirstColumn(); columnIndex <= range.getLastColumn(); columnIndex++) { + if (rowIndex != range.getFirstRow() || columnIndex != range.getFirstColumn()) { + mergedCovered.add(cellKey(rowIndex, columnIndex)); + } + } + } + } + + Map> imageUrlsByCell = new HashMap<>(); + for (ExcelEmbeddedImage image : embeddedImages) { + int imageRow = image.rowIndex; + int imageColumn = image.columnIndex; + for (CellRangeAddress range : sheet.getMergedRegions()) { + if (range.isInRange(imageRow, imageColumn)) { + imageRow = range.getFirstRow(); + imageColumn = range.getFirstColumn(); + break; + } + } + imageUrlsByCell.computeIfAbsent(cellKey(imageRow, imageColumn), key -> new ArrayList<>()) + .add(image.imageUrl); + } + + StringBuilder html = new StringBuilder(""); + for (int rowIndex = minRow; rowIndex <= maxRow; rowIndex++) { + Row row = sheet.getRow(rowIndex); + html.append(""); + for (int columnIndex = minColumn; columnIndex <= maxColumn; columnIndex++) { + String key = cellKey(rowIndex, columnIndex); + if (mergedCovered.contains(key)) { + continue; + } + Cell cell = row == null ? null : row.getCell(columnIndex); + CellRangeAddress range = mergedStarts.get(key); + html.append(" 1) { + html.append(" rowspan=\"").append(rowSpan).append("\""); + } + if (columnSpan > 1) { + html.append(" colspan=\"").append(columnSpan).append("\""); + } + } + html.append(" style=\"").append(buildCellStyle(workbook, cell)).append("\">") + .append(buildCellHtml(cell, formatter, evaluator, imageUrlsByCell.get(key))) + .append(""); + } + html.append(""); + } + return html.append("
").toString(); + } + + /** + * 提取页签绘图层中的嵌入图片,图片锚点用于确定其所属单元格。 + */ + private List extractSheetImages(Sheet sheet) { + List images = new ArrayList<>(); + if (sheet instanceof XSSFSheet xssfSheet) { + XSSFDrawing drawing = xssfSheet.getDrawingPatriarch(); + if (drawing != null) { + for (XSSFShape shape : drawing.getShapes()) { + if (shape instanceof XSSFPicture picture && picture.getAnchor() instanceof XSSFClientAnchor anchor) { + addExcelEmbeddedImage(images, anchor.getRow1(), anchor.getCol1(), picture.getPictureData()); + } + } + } + } else if (sheet instanceof HSSFSheet hssfSheet) { + HSSFPatriarch drawing = hssfSheet.getDrawingPatriarch(); + if (drawing != null) { + for (HSSFShape shape : drawing.getChildren()) { + if (shape instanceof HSSFPicture picture && picture.getAnchor() instanceof HSSFClientAnchor anchor) { + addExcelEmbeddedImage(images, anchor.getRow1(), anchor.getCol1(), picture.getPictureData()); + } + } + } + } + return images; + } + + /** + * 将Excel嵌入图片写入现有文件存储,并记录图片对应的单元格位置。 + */ + private void addExcelEmbeddedImage(List images, int rowIndex, int columnIndex, + PictureData pictureData) { + if (pictureData == null || pictureData.getData() == null || pictureData.getData().length < 1) { + return; + } + String extension = StrUtil.blankToDefault(pictureData.suggestFileExtension(), "png") + .replaceAll("[^a-zA-Z0-9]", ""); + if (StrUtil.isBlank(extension)) { + extension = "png"; + } + String imageUrl = storageFile(SysFileEngineTypeEnum.MINIO.getValue(), + R.UU32() + "." + extension.toLowerCase(), pictureData.getData(), false); + images.add(new ExcelEmbeddedImage(rowIndex, columnIndex, imageUrl)); + } + + private String getCellDisplayValue(Cell cell, DataFormatter formatter, FormulaEvaluator evaluator) { + if (cell == null) { + return ""; + } + try { + return formatter.formatCellValue(cell, evaluator); + } catch (Exception ignored) { + return formatter.formatCellValue(cell); + } + } + + private String formatCellHtml(Cell cell, DataFormatter formatter, FormulaEvaluator evaluator) { + String value = Strings.escapeHtml(getCellDisplayValue(cell, formatter, evaluator)); + return StrUtil.isBlank(value) ? " " : value.replace("\r\n", "
").replace("\n", "
"); + } + + private String buildCellHtml(Cell cell, DataFormatter formatter, FormulaEvaluator evaluator, + List imageUrls) { + String textHtml = formatCellHtml(cell, formatter, evaluator); + if (imageUrls == null || imageUrls.isEmpty()) { + return textHtml; + } + StringBuilder html = new StringBuilder(); + if (!" ".equals(textHtml)) { + html.append("
").append(textHtml).append("
"); + } + for (String imageUrl : imageUrls) { + html.append(""); + } + return html.toString(); + } + + private String buildCellStyle(Workbook workbook, Cell cell) { + StringBuilder styleText = new StringBuilder("padding:6px 8px;border:1px solid #dcdfe6;"); + if (cell == null) { + return styleText.toString(); + } + CellStyle cellStyle = cell.getCellStyle(); + HorizontalAlignment alignment = cellStyle.getAlignment(); + if (alignment == HorizontalAlignment.CENTER || alignment == HorizontalAlignment.CENTER_SELECTION) { + styleText.append("text-align:center;"); + } else if (alignment == HorizontalAlignment.RIGHT) { + styleText.append("text-align:right;"); + } else { + styleText.append("text-align:left;"); + } + VerticalAlignment verticalAlignment = cellStyle.getVerticalAlignment(); + if (verticalAlignment == VerticalAlignment.TOP) { + styleText.append("vertical-align:top;"); + } else if (verticalAlignment == VerticalAlignment.BOTTOM) { + styleText.append("vertical-align:bottom;"); + } else { + styleText.append("vertical-align:middle;"); + } + styleText.append(cellStyle.getWrapText() ? "white-space:pre-wrap;" : "white-space:nowrap;"); + + Font font = workbook.getFontAt(cellStyle.getFontIndex()); + if (font.getBold()) { + styleText.append("font-weight:bold;"); + } + if (font.getItalic()) { + styleText.append("font-style:italic;"); + } + if (font.getFontHeightInPoints() > 0) { + styleText.append("font-size:").append(font.getFontHeightInPoints()).append("pt;"); + } + appendIndexedColor(styleText, "color", font.getColor()); + if (cellStyle.getFillPattern() != FillPatternType.NO_FILL) { + appendIndexedColor(styleText, "background-color", cellStyle.getFillForegroundColor()); + } + if (cellStyle.getBorderTop() != BorderStyle.NONE || cellStyle.getBorderRight() != BorderStyle.NONE + || cellStyle.getBorderBottom() != BorderStyle.NONE || cellStyle.getBorderLeft() != BorderStyle.NONE) { + styleText.append("border:1px solid #909399;"); + } + return styleText.toString(); + } + + private void appendIndexedColor(StringBuilder styleText, String cssName, short colorIndex) { + try { + HSSFColor color = HSSFColor.getIndexHash().get(colorIndex); + if (color != null) { + short[] rgb = color.getTriplet(); + styleText.append(cssName).append(":#") + .append(String.format("%02x%02x%02x", rgb[0], rgb[1], rgb[2])) + .append(";"); + } + } catch (Exception ignored) { + // 自定义主题色无法直接映射时保留浏览器默认颜色,不影响表格内容导入。 + } + } + + private String cellKey(int rowIndex, int columnIndex) { + return rowIndex + "_" + columnIndex; + } + + /** + * Excel嵌入图片及其左上角锚点信息。 + */ + private static class ExcelEmbeddedImage { + private final int rowIndex; + private final int columnIndex; + private final String imageUrl; + + private ExcelEmbeddedImage(int rowIndex, int columnIndex, String imageUrl) { + this.rowIndex = rowIndex; + this.columnIndex = columnIndex; + this.imageUrl = imageUrl; + } + } + + private void validateFileExtension(TempFile file, List allowedExtensions, String errorMessage) { + if (file == null || file.getFile() == null || !file.getFile().exists()) { + throw new BaseException("文件内容为空"); + } + String extension = FileUtil.extName(file.getSubmittedFileName()); + if (StrUtil.isBlank(extension) || !allowedExtensions.contains(extension.toLowerCase())) { + throw new BaseException(errorMessage); + } + } + + private void validatePdfSafety(PDDocument document) { + COSDictionary catalog = document.getDocumentCatalog().getCOSObject(); + if (catalog.containsKey(COSName.NAMES)) { + COSDictionary names = (COSDictionary) catalog.getDictionaryObject(COSName.NAMES); + if (names != null && names.containsKey(COSName.JAVA_SCRIPT)) { + throw new BaseException("系统检测到此PDF文件具有一定危险性"); + } + } + if (catalog.containsKey(COSName.OPEN_ACTION)) { + Object openAction = catalog.getDictionaryObject(COSName.OPEN_ACTION); + if (openAction instanceof COSDictionary action + && COSName.JAVA_SCRIPT.equals(action.getDictionaryObject(COSName.S))) { + throw new BaseException("系统检测到此PDF文件具有一定危险性"); + } + } + } + /** * 存储文件 **/ diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpManageController.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpManageController.java index 21fc469..b6d5f01 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpManageController.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpManageController.java @@ -14,6 +14,7 @@ import com.budwk.app.sys.models.Sys_unit; import com.budwk.app.zhgh.activity.trainSignUp.models.*; import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService; import com.budwk.app.zhgh.club.model.SysClub; +import com.budwk.app.zhgh.welfare.service.WelfareProjectService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; @@ -22,6 +23,8 @@ import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.util.NutMap; @@ -52,6 +55,8 @@ public class TrainSignUpManageController { private TrainSignUpActivityService trainSignUpActivityManageService; @Inject private Dao dao; + @Inject + private WelfareProjectService welfareProjectService; @At("") @SaCheckPermission("trainSignUp.manage") @@ -90,6 +95,34 @@ public class TrainSignUpManageController { return Result.success(); } + /** 接收主活动和子活动 ID;Result.data 为当前预计接收人数。 */ + @At + @ApiOperation("查询子活动报名提醒人数") + @SaCheckPermission("trainSignUp.manage") + public Result reminderRecipientCount(@Param("activityId") String activityId, @Param("courseId") String courseId) { + try { + return Result.success().addData(trainSignUpActivityManageService.countReminderRecipients(activityId, courseId)); + } catch (IllegalArgumentException e) { + return Result.error(e.getMessage()); + } + } + + /** 接收主活动、子活动 ID 和提醒正文;成功表示已提交后台发送,data 为本批次人数。 */ + @At + @ApiOperation("提交子活动报名提醒") + @SaCheckPermission("trainSignUp.manage") + @SLog(tag = "品牌活动-活动管理", msg = "提交子活动报名提醒") + @Aop(TransAop.READ_COMMITTED) + public Result submitSignupReminder(@Param("activityId") String activityId, @Param("courseId") String courseId, + @Param("content") String content) { + try { + int count = trainSignUpActivityManageService.submitSignupReminder(activityId, courseId, content); + return Result.success("已提交发送,接收人数:" + count).addData(count); + } catch (IllegalArgumentException e) { + return Result.error(e.getMessage()); + } + } + @At @ApiOperation("活动状态变更") @SaCheckPermission("trainSignUp.manage") @@ -153,13 +186,25 @@ public class TrainSignUpManageController { @ApiOperation("品牌活动新增/修改") @SaCheckPermission("trainSignUp.manage") @SLog(tag = "品牌活动-活动管理", msg = "新增/修改活动") + @Aop(TransAop.READ_COMMITTED) public Result doHandle(TrainSignUpActivity activity) { - if (StrUtil.isBlank(activity.getId())) { - trainSignUpActivityManageService.add(activity, null); - } else { - trainSignUpActivityManageService.edit(activity); + try { + if (StrUtil.isBlank(activity.getId())) { + trainSignUpActivityManageService.add(activity, null); + } else { + trainSignUpActivityManageService.edit(activity); + } + return Result.success(); + } catch (IllegalArgumentException e) { + return Result.error(99, e.getMessage()); } - return Result.success(); + } + + @At + @ApiOperation("查询签到扩展可关联的福利项目") + @SaCheckPermission("trainSignUp.manage") + public Result getWelfareProjects(Integer year) { + return Result.success(welfareProjectService.getWelfareList(year)); } @At diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainsignUpMineController.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainsignUpMineController.java index e92c3a2..57d23a2 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainsignUpMineController.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainsignUpMineController.java @@ -10,6 +10,7 @@ import com.budwk.app.base.result.Result; import com.budwk.app.base.utils.DistanceUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpCourse; +import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpActivity; import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpUserCourse; import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService; import io.swagger.annotations.Api; @@ -23,6 +24,7 @@ import org.nutz.dao.sql.Sql; import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.Lang; import org.nutz.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; @@ -75,6 +77,18 @@ public class TrainsignUpMineController { @SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR) @SLog(tag = "品牌活动-活动签到", msg = "主动扫码签到") public Result drivingScan(String courseId, Double lat, Double lng) { + TrainSignUpCourse extensionCourse = dao.fetch(TrainSignUpCourse.class, courseId); + TrainSignUpActivity extensionActivity = extensionCourse == null ? null + : dao.fetch(TrainSignUpActivity.class, extensionCourse.getActivityId()); + if (extensionActivity != null && extensionActivity.getSignPointCount() != null + && extensionActivity.getSignPointCount() > 0) { + try { + return Result.success("签到成功").addData(activityService.handleScanCodeSign( + courseId, SecurityUtil.getUserId(), lat, lng)); + } catch (IllegalArgumentException e) { + return Result.error(99, e.getMessage()); + } + } // 主动扫码签到是用户自己打开扫一扫,扫二维码签到 int count = dao.count(TrainSignUpUserCourse.class, Cnd.where("courseId", "=", courseId).and("userId", "=", SecurityUtil.getUserId())); @@ -113,6 +127,42 @@ public class TrainsignUpMineController { return Result.success("签到成功"); } + /** + * @param courseId 扫码二维码中携带的课程点位ID + * @return Result.data为地图点位、签到进度、处理方式和动态表单配置 + */ + @At + @ApiOperation("查询签到扩展页面数据") + @SaCheckPermission("h5.trainSignUp.mine") + public Result getSignData(String courseId) { + try { + return Result.success().addData(activityService.getScanCodeSignData(courseId, SecurityUtil.getUserId())); + } catch (IllegalArgumentException e) { + return Result.error(99, e.getMessage()); + } + } + + /** + * @param courseId 当前扫码课程ID + * @param mobileColumnsValue JSON数组,每项包含字段编码和值 + * @return Result,code为0表示保存成功,99表示业务校验未通过 + */ + @At + @ApiOperation("保存签到达标后的填写信息") + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("h5.trainSignUp.mine") + public Result saveDynamicForm(String courseId, String mobileColumnsValue) { + if (courseId == null || mobileColumnsValue == null) { + return Result.error(99, "请填写签到信息"); + } + try { + activityService.saveScanCodeDynamicForm(courseId, SecurityUtil.getUserId(), mobileColumnsValue); + return Result.success("保存成功"); + } catch (IllegalArgumentException e) { + return Result.error(99, e.getMessage()); + } + } + @At @ApiOperation("被动扫码签到") @Aop(TransAop.READ_COMMITTED) @@ -217,6 +267,13 @@ public class TrainsignUpMineController { @SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR) public Result fetchCourse(String courseId) { TrainSignUpCourse course = dao.fetch(TrainSignUpCourse.class, courseId); - return Result.success(course); + if (course == null) { + return Result.error(99, "未找到签到课程"); + } + TrainSignUpActivity activity = dao.fetch(TrainSignUpActivity.class, course.getActivityId()); + NutMap data = Lang.obj2nutmap(course); + data.put("signExtensionEnabled", activity != null && activity.getSignPointCount() != null + && activity.getSignPointCount() > 0); + return Result.success(data); } } diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpActivity.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpActivity.java index 2e7809c..132cb0c 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpActivity.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpActivity.java @@ -124,6 +124,21 @@ public class TrainSignUpActivity extends BaseModel implements Serializable, SysH @ColDefine(type = ColType.MYSQL_JSON) private List helpUnits; + @Column + @ColDefine(type = ColType.INT) + @Comment("签到达标点位数,空或0表示未开启签到扩展") + private Integer signPointCount; + + @Column + @ColDefine(type = ColType.INT) + @Comment("签到达标处理方式:1填写信息,2选择福利,3无") + private Integer signHandleType; + + @Column + @ColDefine(type = ColType.VARCHAR, width = 32) + @Comment("签到达标后关联的福利项目ID") + private String welfareProjectId; + @Override public Sys_home_activity covertToSysHomeActivity() { Sys_home_activity sysHomeActivity = new Sys_home_activity(); diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/TrainSignUpActivityService.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/TrainSignUpActivityService.java index f7a3c54..4520b6b 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/TrainSignUpActivityService.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/TrainSignUpActivityService.java @@ -77,6 +77,12 @@ public interface TrainSignUpActivityService extends BaseService qdInfoByUserId(String userId, String activityId); List filterCourseByHostUnion(List courseList); + + /** + * 查询扩展签到页面数据。 + * + * @param courseId 扫码进入的课程点位ID + * @param userId 当前登录用户ID + * @return 地图点位、签到进度、达标处理方式及动态表单字段 + */ + NutMap getScanCodeSignData(String courseId, String userId); + + /** + * 完成扩展点位签到。 + * + * @param courseId 扫码进入的课程点位ID + * @param userId 当前登录用户ID + * @param latitude 浏览器定位得到的WGS84纬度 + * @param longitude 浏览器定位得到的WGS84经度 + * @return 最新签到进度及达标后的处理信息 + */ + NutMap handleScanCodeSign(String courseId, String userId, Double latitude, Double longitude); + + /** + * 保存签到达标后填写的动态表单。 + * + * @param courseId 当前扫码课程ID + * @param userId 当前登录用户ID + * @param mobileColumnsValue JSON数组,每项包含columnCode和columnValue + */ + void saveScanCodeDynamicForm(String courseId, String userId, String mobileColumnsValue); } diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityServiceImpl.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityServiceImpl.java index 7de5258..c85ba79 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityServiceImpl.java @@ -1,6 +1,9 @@ package com.budwk.app.zhgh.activity.trainSignUp.service.impl; import cn.hutool.core.util.StrUtil; +import cn.hutool.core.thread.ThreadUtil; +import cn.hutool.core.date.DateUtil; +import com.budwk.app.base.sms.SmsService; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.param.PageForm; import com.budwk.app.base.service.impl.BaseServiceImpl; @@ -14,6 +17,8 @@ import com.budwk.app.zhgh.activity.family.service.FamilyActivityStatisticsServic import com.budwk.app.zhgh.activity.trainSignUp.models.*; import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService; import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService; +import com.budwk.app.zhgh.welfare.model.WelfareProject; +import com.budwk.app.zhgh.welfare.service.WelfareListService; import com.google.common.collect.Sets; import lombok.extern.slf4j.Slf4j; import org.nutz.aop.interceptor.async.Async; @@ -27,6 +32,7 @@ import org.nutz.dao.util.cri.Static; import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; import org.nutz.lang.Lang; import org.nutz.lang.util.NutMap; @@ -45,6 +51,84 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl recipients = listReminderRecipients(activityId, courseId); + if (recipients.isEmpty()) { + throw new IllegalArgumentException("该子活动没有符合条件的未报名人员"); + } + String sender = SecurityUtil.getUserLoginname(); + if (StrUtil.isBlank(sender)) { + throw new IllegalArgumentException("无法获取发送人工号"); + } + String url = "/platform/trainSignUp/apply/h5?id=" + activityId; + String message = content.trim(); + ThreadUtil.execute(() -> smsService.massSendTextCard(recipients, "品牌活动报名提醒", message, null, url, sender)); + return recipients.size(); + } + + /** + * 根据主活动范围和子活动报名记录返回工号列表;其他子活动的报名不影响本次筛选。 + * 停用、过期或子活动归属不匹配时拒绝发送,空人员范围返回空列表。 + */ + private List listReminderRecipients(String activityId, String courseId) { + if (StrUtil.isBlank(activityId) || StrUtil.isBlank(courseId)) { + throw new IllegalArgumentException("请选择子活动"); + } + TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, activityId); + TrainSignUpCourse course = dao().fetch(TrainSignUpCourse.class, courseId); + if (activity == null || course == null || !activityId.equals(course.getActivityId())) { + throw new IllegalArgumentException("当前活动或子活动不存在"); + } + Date now = new Date(); + if (activity.isDisabled() || activity.getActivitySignUpEndTime() == null + || !now.before(activity.getActivitySignUpEndTime())) { + throw new IllegalArgumentException("活动已停用或报名已截止,无法发送提醒"); + } + if (activity.getActivityGroupId() == null) { + return List.of(); + } + // 与子活动对内开放规则一致:开放时间前只提醒承办工会的人员。 + boolean hostOnly = Boolean.FALSE.equals(course.getOpenOtherUnion()) + && StrUtil.isNotBlank(course.getInterTime()) + && DateUtil.compare(now, DateUtil.parse(course.getInterTime()), "yyyy-MM-dd HH:mm") < 0; + Set allowedUnions = Lang.isEmpty(course.getUnionLimit()) ? Set.of() + : course.getUnionLimit().stream().map(v -> v.getString("id")).filter(StrUtil::isNotBlank).collect(Collectors.toSet()); + Sql sql = Sqls.create(""" + SELECT DISTINCT u.loginname, u.unionId + FROM activity_user_scope s + JOIN vw_user u ON u.id = s.userId + WHERE s.groupId = @groupId + AND u.loginname IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM train_sign_up_user r + WHERE r.courseId = @courseId AND r.userId = s.userId + ) + """); + sql.setParam("groupId", activity.getActivityGroupId()); + sql.setParam("courseId", courseId); + return listMap(sql).stream() + .filter(row -> !hostOnly || Objects.equals(course.getHostUnionId(), row.getString("unionId"))) + .filter(row -> Lang.isEmpty(course.getUnionLimit()) || allowedUnions.contains(row.getString("unionId"))) + .map(row -> row.getString("loginname")) + .filter(StrUtil::isNotBlank).map(String::trim).distinct().toList(); + } public TrainSignUpActivityServiceImpl(Dao dao) { super(dao); @@ -53,6 +137,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl records = dao().query(TrainSignUpUserCourse.class, + Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId) + .and("userId", "=", userId).asc("courseStartTime")); + if (records.stream().anyMatch(TrainSignUpUserCourse::isAttend)) { + return buildScanCodeData(activity, course, userId, true) + .addv("message", "此点位,您已经签到"); + } + + Date now = new Date(); + int signUnit = course.getSignUnit() == null ? 0 : course.getSignUnit(); + List availableRecords = records.stream().filter(item -> item.getCourseStartTime() != null + && item.getCourseEndTime() != null + && !now.before(cn.hutool.core.date.DateUtil.offsetMinute(item.getCourseStartTime(), -signUnit)) + && !now.after(cn.hutool.core.date.DateUtil.offsetMinute(item.getCourseEndTime(), signUnit))) + .collect(Collectors.toList()); + if (availableRecords.size() != 1) { + throw new IllegalArgumentException(availableRecords.isEmpty() ? "当前不在签到时间范围内" : "当前点位存在多个可签到课次,请联系管理员"); + } + TrainSignUpUserCourse userCourse = availableRecords.get(0); + userCourse.setAttend(true); + userCourse.setAttendTime(now); + dao().update(userCourse); + return buildScanCodeData(activity, course, userId, true).addv("message", "签到成功"); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void saveScanCodeDynamicForm(String courseId, String userId, String mobileColumnsValue) { + lockScanCodeUser(userId); + TrainSignUpCourse course = requireScanCodeCourse(courseId, userId); + TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, course.getActivityId()); + if (!Integer.valueOf(1).equals(activity.getSignHandleType())) { + throw new IllegalArgumentException("当前活动无需填写信息"); + } + if (!buildScanCodeData(activity, course, userId, false).getBoolean("isReachTarget", false)) { + throw new IllegalArgumentException("当前活动尚未达到签到点位数"); + } + List inputData; + try { + inputData = Json.fromJsonAsList(NutMap.class, mobileColumnsValue); + } catch (Exception e) { + throw new IllegalArgumentException("填写信息格式不正确"); + } + if (inputData == null) { + throw new IllegalArgumentException("请填写当前签到信息"); + } + Map inputMap = new LinkedHashMap<>(); + for (NutMap item : inputData) { + String code = item == null ? null : item.getString("columnCode"); + if (StrUtil.isBlank(code) || inputMap.put(code, item) != null) { + throw new IllegalArgumentException("填写信息包含无效或重复字段"); + } + } + List columns = getScanCodeColumns(course); + List normalized = new ArrayList<>(); + for (TrainMobileSignColumn column : columns) { + NutMap input = inputMap.get(column.getColumnCode()); + Object value = input == null ? null : input.get("columnValue"); + if (Boolean.TRUE.equals(column.getIsRequired()) && isEmptyDynamicValue(value)) { + throw new IllegalArgumentException("请填写" + column.getColumnName()); + } + normalized.add(NutMap.NEW().addv("columnCode", column.getColumnCode()) + .addv("columnName", column.getColumnName()).addv("columnFormType", column.getColumnFormType()) + .addv("columnValue", value)); + } + List signUsers = dao().query(TrainSignUpUser.class, + Cnd.where("activityId", "=", activity.getId()).and("userId", "=", userId)); + signUsers.forEach(item -> item.setMobileColumnsValue(normalized)); + dao().update(signUsers); + } + + /** 保存活动前统一校验签到扩展,未开启时清理无效配置,避免历史活动被新逻辑影响。 */ + private void validateSignExtension(TrainSignUpActivity activity) { + if (activity.getSignPointCount() == null || activity.getSignPointCount() <= 0) { + activity.setSignPointCount(null); + activity.setSignHandleType(3); + activity.setWelfareProjectId(null); + return; + } + List courses = activity.getCourseList(); + if (Lang.isEmpty(courses) || activity.getSignPointCount() > courses.size()) { + throw new IllegalArgumentException("签到达标点位数不能超过活动课程数量"); + } + for (TrainSignUpCourse item : courses) { + if (!item.isMobileSign() || !Integer.valueOf(1).equals(item.getSignType())) { + throw new IllegalArgumentException("开启签到扩展后,所有课程必须启用扫码签到"); + } + if (!validCoordinates(item.getCourseLocationCoordinates()) || !validCoordinates(item.getTransPosition())) { + throw new IllegalArgumentException("课程“" + item.getCourseName() + "”未配置有效签到坐标"); + } + if (item.getRadius() != null && item.getRadius() <= 0) { + throw new IllegalArgumentException("课程“" + item.getCourseName() + "”签到半径必须大于0"); + } + } + Integer handleType = activity.getSignHandleType() == null ? 3 : activity.getSignHandleType(); + if (!List.of(1, 2, 3).contains(handleType)) { + throw new IllegalArgumentException("签到达标处理方式无效"); + } + activity.setSignHandleType(handleType); + if (handleType == 1 && (courses.stream().anyMatch(item -> StrUtil.isBlank(item.getCourseType())) + || courses.stream().map(TrainSignUpCourse::getCourseType).distinct().count() != 1)) { + throw new IllegalArgumentException("达标后填写信息要求所有课程使用同一种课程类型"); + } + if (handleType == 2) { + WelfareProject project = StrUtil.isBlank(activity.getWelfareProjectId()) ? null + : dao().fetch(WelfareProject.class, activity.getWelfareProjectId()); + if (project == null) { + throw new IllegalArgumentException("请选择有效的福利项目"); + } + if (Boolean.TRUE.equals(project.getIsDisabled()) + || project.getChoiceTimeEnd() != null && project.getChoiceTimeEnd().before(new Date())) { + throw new IllegalArgumentException("所选福利项目已禁用或已超过选择截止时间"); + } + } else { + activity.setWelfareProjectId(null); + } + } + + /** 校验扩展签到入口,不改变当前项目“必须先报名才能签到”的原有规则。 */ + private TrainSignUpCourse requireScanCodeCourse(String courseId, String userId) { + TrainSignUpCourse course = StrUtil.isBlank(courseId) ? null : dao().fetch(TrainSignUpCourse.class, courseId); + if (course == null || !course.isMobileSign() || !Integer.valueOf(1).equals(course.getSignType())) { + throw new IllegalArgumentException("当前课程未开启扫码签到"); + } + TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, course.getActivityId()); + if (activity == null || activity.getSignPointCount() == null || activity.getSignPointCount() <= 0) { + throw new IllegalArgumentException("当前活动未开启签到扩展"); + } + Date now = new Date(); + if (activity.isDisabled() || activity.getActivityStartTime() == null || activity.getActivityEndTime() == null + || now.before(activity.getActivityStartTime()) || now.after(activity.getActivityEndTime())) { + throw new IllegalArgumentException("当前活动未开放或不在活动时间范围内"); + } + List signUsers = dao().query(TrainSignUpUser.class, + Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId).and("userId", "=", userId)); + if (signUsers.isEmpty()) { + throw new IllegalArgumentException("未查询到您的报名记录"); + } + if (signUsers.stream().noneMatch(item -> Integer.valueOf(1).equals(item.getState()) || Integer.valueOf(3).equals(item.getState()))) { + throw new IllegalArgumentException("当前报名状态不能签到"); + } + return course; + } + + /** 组装当前点位、全部点位、签到进度及达标处理状态。 */ + private NutMap buildScanCodeData(TrainSignUpActivity activity, TrainSignUpCourse course, String userId, boolean allowHandle) { + List attended = dao().query(TrainSignUpUserCourse.class, + Cnd.where("activityId", "=", activity.getId()).and("userId", "=", userId).and("isAttend", "=", true)); + long signCount = attended.stream().map(TrainSignUpUserCourse::getCourseId).filter(StrUtil::isNotBlank).distinct().count(); + boolean reached = signCount >= activity.getSignPointCount(); + List courses = dao().query(TrainSignUpCourse.class, + Cnd.where("activityId", "=", activity.getId()).asc("orderNum")); + List signPoints = courses.stream().map(item -> NutMap.NEW().addv("courseId", item.getId()) + .addv("courseName", item.getCourseName()).addv("courseLocation", item.getCourseLocation()) + .addv("signed", attended.stream().anyMatch(record -> Objects.equals(record.getCourseId(), item.getId())))) + .collect(Collectors.toList()); + Map savedValues = new HashMap<>(); + List signUsers = dao().query(TrainSignUpUser.class, + Cnd.where("activityId", "=", activity.getId()).and("userId", "=", userId)); + signUsers.stream().map(TrainSignUpUser::getMobileColumnsValue).filter(Lang::isNotEmpty).findFirst() + .ifPresent(values -> values.forEach(value -> savedValues.put(value.getString("columnCode"), value.get("columnValue")))); + List dynamicColumns = getScanCodeColumns(course).stream().map(item -> { + NutMap map = Lang.obj2nutmap(item); + map.put("columnValue", savedValues.containsKey(item.getColumnCode()) ? savedValues.get(item.getColumnCode()) + : "JSON".equals(item.getColumnType()) ? new ArrayList<>() : ""); + return map; + }).collect(Collectors.toList()); + NutMap data = NutMap.NEW().addv("activityName", activity.getActivityName()).addv("courseName", course.getCourseName()) + .addv("courseLocation", course.getCourseLocation()).addv("courseLocationCoordinates", course.getCourseLocationCoordinates()) + .addv("signRadius", effectiveRadius(course)).addv("currentPointSigned", attended.stream() + .anyMatch(item -> Objects.equals(item.getCourseId(), course.getId()))) + .addv("signCount", signCount).addv("signPointCount", activity.getSignPointCount()) + .addv("signPoints", signPoints).addv("isReachTarget", reached) + .addv("signHandleType", activity.getSignHandleType()).addv("dynamicColumnsData", dynamicColumns) + .addv("needDynamicForm", reached && Integer.valueOf(1).equals(activity.getSignHandleType()) && !dynamicColumns.isEmpty()) + .addv("needWelfareSelect", false).addv("welfareJumpUrl", "") + .addv("handleMsg", reached ? "当前活动已达标" : "请继续完成其他点位签到"); + if (reached && Integer.valueOf(2).equals(activity.getSignHandleType())) { + WelfareProject welfareProject = dao().fetch(WelfareProject.class, activity.getWelfareProjectId()); + if (welfareProject == null || Boolean.TRUE.equals(welfareProject.getIsDisabled()) + || welfareProject.getChoiceTimeEnd() != null && welfareProject.getChoiceTimeEnd().before(new Date())) { + return data.setv("handleMsg", "关联福利项目已结束,请联系管理员"); + } + if (allowHandle) { + welfareListService.ensureWelfareListUser(userId, activity.getWelfareProjectId()); + } + data.setv("needWelfareSelect", true) + .setv("welfareJumpUrl", "/platform/h5/welfare/userSelect/index?id=" + activity.getWelfareProjectId()) + .setv("handleMsg", "签到已达标,请选择福利"); + } + return data; + } + + private List getScanCodeColumns(TrainSignUpCourse course) { + return StrUtil.isBlank(course.getCourseType()) ? new ArrayList<>() : dao().query(TrainMobileSignColumn.class, + Cnd.where("typeId", "=", course.getCourseType()).asc("columnIndex")); + } + + /** 锁定当前登录用户,避免同一用户并发扫码造成重复达标处理。 */ + private void lockScanCodeUser(String userId) { + if (StrUtil.isBlank(userId)) { + throw new IllegalArgumentException("未获取到当前登录用户"); + } + Sql sql = Sqls.create("SELECT id FROM sys_user WHERE id = @userId FOR UPDATE").setParam("userId", userId); + sql.setCallback(Sqls.callback.map()); + dao().execute(sql); + NutMap user = sql.getObject(NutMap.class); + if (user == null || user.isEmpty()) { + throw new IllegalArgumentException("未找到当前用户信息"); + } + } + + private int effectiveRadius(TrainSignUpCourse course) { + return course.getRadius() == null ? 100 : course.getRadius(); + } + + private boolean validCoordinates(List coordinates) { + return coordinates != null && coordinates.size() == 2 && coordinates.get(0) != null && coordinates.get(1) != null + && Double.isFinite(coordinates.get(0)) && Double.isFinite(coordinates.get(1)); + } + + /** 当前定位与transPosition均为WGS84坐标,沿用原签到距离计算口径。 */ + private void requireScanCodeLocation(List coordinates, Double latitude, Double longitude, int radius) { + if (!validCoordinates(coordinates)) { + throw new IllegalArgumentException("当前点位未配置有效签到坐标"); + } + if (latitude == null || longitude == null || !Double.isFinite(latitude) || !Double.isFinite(longitude)) { + throw new IllegalArgumentException("请先获取当前定位后再签到"); + } + double distance = com.budwk.app.base.utils.DistanceUtil.getDistance(coordinates.get(0), coordinates.get(1), latitude, longitude); + if (distance > radius) { + throw new IllegalArgumentException("签到失败,您距离签到点还有" + String.format("%.1f", distance - radius) + "米"); + } + } + + private boolean isEmptyDynamicValue(Object value) { + return value == null || value instanceof String && StrUtil.isBlank((String) value) + || value instanceof Collection && ((Collection) value).isEmpty(); + } + @Override public List filterCourseByHostUnion(List courseList) { if (Lang.isEmpty(courseList)) { diff --git a/src/main/java/com/budwk/app/zhgh/map/TMapController.java b/src/main/java/com/budwk/app/zhgh/map/TMapController.java index 9b89735..5328912 100644 --- a/src/main/java/com/budwk/app/zhgh/map/TMapController.java +++ b/src/main/java/com/budwk/app/zhgh/map/TMapController.java @@ -31,7 +31,7 @@ import javax.validation.Valid; @At("/platform/TMap") public class TMapController { - private static String KEY = "MLLBZ-GQECI-ASXG7-5GNOZ-XW2OF-H5BVH"; + private static String KEY = "NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU"; @Inject private SysConfigService configService; diff --git a/src/main/java/com/budwk/app/zhgh/welfare/controller/WelfareProjectMangeController.java b/src/main/java/com/budwk/app/zhgh/welfare/controller/WelfareProjectMangeController.java index 6fad271..0e3ce0f 100644 --- a/src/main/java/com/budwk/app/zhgh/welfare/controller/WelfareProjectMangeController.java +++ b/src/main/java/com/budwk/app/zhgh/welfare/controller/WelfareProjectMangeController.java @@ -42,6 +42,31 @@ public class WelfareProjectMangeController { @Inject private WelfareListService welfareListService; + /** projectId 为福利项目 ID;Result.data 为预计接收人数。 */ + @At + @SaCheckPermission("welfare.project.mange") + public Result reminderRecipientCount(@Param("projectId") String projectId) { + try { + return Result.success().addData(projectService.countReminderRecipients(projectId)); + } catch (IllegalArgumentException e) { + return Result.error(e.getMessage()); + } + } + + /** projectId 为项目 ID,content 为提醒正文;成功仅表示已提交发送,data 为接收人数。 */ + @At + @SaCheckPermission("welfare.project.mange") + @SLog(tag = "福利", msg = "提交未选择人员提醒") + @Aop(TransAop.READ_COMMITTED) + public Result submitSelectionReminder(@Param("projectId") String projectId, @Param("content") String content) { + try { + int count = projectService.submitSelectionReminder(projectId, content); + return Result.success("已提交发送,接收人数:" + count).addData(count); + } catch (IllegalArgumentException e) { + return Result.error(e.getMessage()); + } + } + @At("") @Ok("beetl:/platform/zhgh/welfare/projectMange/index.html") @SaCheckPermission("welfare.project.mange") diff --git a/src/main/java/com/budwk/app/zhgh/welfare/service/WelfareListService.java b/src/main/java/com/budwk/app/zhgh/welfare/service/WelfareListService.java index 6b231b3..c802569 100644 --- a/src/main/java/com/budwk/app/zhgh/welfare/service/WelfareListService.java +++ b/src/main/java/com/budwk/app/zhgh/welfare/service/WelfareListService.java @@ -26,6 +26,14 @@ public interface WelfareListService extends BaseService { void doWelfareListUser(String[] ids,String id); + /** + * 将单个用户加入福利项目名单,已存在时不重复添加。 + * + * @param userId 用户ID + * @param projectId 福利项目ID + */ + void ensureWelfareListUser(String userId, String projectId); + /** * 分页查询 * @param pageForm diff --git a/src/main/java/com/budwk/app/zhgh/welfare/service/WelfareProjectService.java b/src/main/java/com/budwk/app/zhgh/welfare/service/WelfareProjectService.java index ed9263d..d965648 100644 --- a/src/main/java/com/budwk/app/zhgh/welfare/service/WelfareProjectService.java +++ b/src/main/java/com/budwk/app/zhgh/welfare/service/WelfareProjectService.java @@ -47,4 +47,10 @@ public interface WelfareProjectService extends BaseService { * @return */ List getWelfareList(Integer year); + + /** projectId 为福利项目 ID,返回名单内尚未选择且有工号的去重人数。 */ + int countReminderRecipients(String projectId); + + /** projectId 为福利项目 ID,content 为提醒正文;返回提交后台发送的人数。 */ + int submitSelectionReminder(String projectId, String content); } diff --git a/src/main/java/com/budwk/app/zhgh/welfare/service/impl/WelfareListServiceImpl.java b/src/main/java/com/budwk/app/zhgh/welfare/service/impl/WelfareListServiceImpl.java index 6bbce8a..eded154 100644 --- a/src/main/java/com/budwk/app/zhgh/welfare/service/impl/WelfareListServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/welfare/service/impl/WelfareListServiceImpl.java @@ -33,6 +33,8 @@ import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.json.Json; import org.nutz.lang.Lang; @@ -381,6 +383,29 @@ public class WelfareListServiceImpl extends BaseServiceImpl impleme insert(welfareLists); } + @Override + @Aop(TransAop.READ_COMMITTED) + public void ensureWelfareListUser(String userId, String projectId) { + if (StrUtil.isBlank(userId) || StrUtil.isBlank(projectId) + || count(Cnd.where("projectId", "=", projectId).and("userId", "=", userId)) > 0) { + return; + } + View_user user = dao().fetch(View_user.class, userId); + if (user == null) { + throw new IllegalArgumentException("未找到当前用户信息,无法加入福利项目"); + } + WelfareList welfareList = new WelfareList(); + welfareList.setProjectId(projectId); + welfareList.setUserId(user.getId()); + welfareList.setWelfareUnitId(user.getUnitId()); + welfareList.setWelfareUnitName(user.getUnitName()); + welfareList.setWelfareUnionId(user.getUnionId()); + welfareList.setWelfareUnionName(user.getUnionName()); + welfareList.setPersonType(user.getPersonType()); + welfareList.setUserState(user.getUserState()); + insert(welfareList); + } + @Override public Pagination pageData(WelfareListPageForm pageForm) { diff --git a/src/main/java/com/budwk/app/zhgh/welfare/service/impl/WelfareProjectServiceImpl.java b/src/main/java/com/budwk/app/zhgh/welfare/service/impl/WelfareProjectServiceImpl.java index 25f5734..c9f79fe 100644 --- a/src/main/java/com/budwk/app/zhgh/welfare/service/impl/WelfareProjectServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/welfare/service/impl/WelfareProjectServiceImpl.java @@ -1,7 +1,11 @@ package com.budwk.app.zhgh.welfare.service.impl; import cn.hutool.core.date.DateUtil; +import cn.hutool.core.thread.ThreadUtil; +import cn.hutool.core.util.StrUtil; import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.base.sms.SmsService; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.sys.models.Sys_home_activity; import com.budwk.app.sys.models.Sys_user; import com.budwk.app.zhgh.welfare.model.WelfareList; @@ -15,6 +19,7 @@ import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Lang; import org.nutz.lang.util.NutMap; @@ -34,10 +39,70 @@ import java.util.stream.Collectors; public class WelfareProjectServiceImpl extends BaseServiceImpl implements WelfareProjectService { private static final Logger log = LoggerFactory.getLogger(WelfareProjectServiceImpl.class); + @Inject + private SmsService smsService; + public WelfareProjectServiceImpl(Dao dao) { super(dao); } + /** 按福利项目 ID 查询未选择人员,返回去重后的接收人数。 */ + @Override + public int countReminderRecipients(String projectId) { + return listReminderRecipients(projectId).size(); + } + + /** 接收项目 ID 和提醒正文,返回后台任务接收人数,不代表送达结果。 */ + @Override + public int submitSelectionReminder(String projectId, String content) { + if (StrUtil.isBlank(content)) { + throw new IllegalArgumentException("请输入提醒内容"); + } + // 预览后可能有人完成选择,提交时重新筛选实际接收人。 + List recipients = listReminderRecipients(projectId); + if (recipients.isEmpty()) { + throw new IllegalArgumentException("该福利项目没有可提醒的未选择人员"); + } + String sender = SecurityUtil.getUserLoginname(); + if (StrUtil.isBlank(sender)) { + throw new IllegalArgumentException("无法获取发送人工号"); + } + String url = "/platform/h5/welfare/userSelect/index?id=" + projectId; + String message = content.trim(); + ThreadUtil.execute(() -> smsService.massSendTextCard(recipients, "福利选择提醒", message, null, url, sender)); + return recipients.size(); + } + + /** + * 返回指定项目福利名单中没有有效选项记录的工号;空名单不扩大查询范围。 + * 项目停用或选择截止时拒绝查询及提交,避免邀请进入无法选择的项目。 + */ + private List listReminderRecipients(String projectId) { + WelfareProject project = StrUtil.isBlank(projectId) ? null : fetch(projectId); + if (project == null) { + throw new IllegalArgumentException("福利项目不存在"); + } + if (Boolean.TRUE.equals(project.getIsDisabled()) || project.getChoiceTimeEnd() == null + || !new Date().before(project.getChoiceTimeEnd())) { + throw new IllegalArgumentException("福利项目已停用或选择已截止,无法发送提醒"); + } + Sql sql = Sqls.create(""" + SELECT DISTINCT u.loginname + FROM welfare_list wl + JOIN sys_user u ON u.id = wl.userId + WHERE wl.projectId = @projectId + AND u.loginname IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM welfare_project_user_selection s + WHERE s.welfareId = wl.projectId AND s.selectUserId = wl.userId + AND s.selectOptionId IS NOT NULL + ) + """); + sql.setParam("projectId", projectId); + return listMap(sql).stream().map(row -> row.getString("loginname")) + .filter(StrUtil::isNotBlank).map(String::trim).distinct().toList(); + } + @Override public WelfareProject projectInfo(String projectId) { WelfareProject project = fetch(projectId); diff --git a/src/main/resources/static/assets/mobile/js/popupHistory.js b/src/main/resources/static/assets/mobile/js/popupHistory.js new file mode 100644 index 0000000..2df3ca6 --- /dev/null +++ b/src/main/resources/static/assets/mobile/js/popupHistory.js @@ -0,0 +1,67 @@ +{ + const owners = {} + const stack = [] + const popupHandledKey = "__mobilePopupHandled" + let sequence = 0 + + function pushHistory(ownerId, popupId) { + const token = "popup-" + (++sequence) + stack.push({ownerId: ownerId, popupId: popupId, token: token}) + // 保留当前 PJAX 状态,避免关闭弹框回退时被识别为页面切换并重新加载外层页面。 + const currentState = window.history.state || {} + const popupState = Object.assign({}, currentState, {mobilePopupToken: token}) + window.history.pushState(popupState, "", window.location.href) + } + + function closeTopFromHistory() { + const item = stack.pop() + if (!item) return false + const callback = owners[item.ownerId] + if (callback) callback(item.popupId) + return true + } + + window.addEventListener("popstate", (event) => { + if (closeTopFromHistory()) { + // 标记本次回退已由弹框处理,供页面中的 PJAX 监听跳过重复处理。 + event[popupHandledKey] = true + event.stopImmediatePropagation() + } + }, true) + + window.mobilePopupHistory = { + register(ownerId, closeCallback) { + owners[ownerId] = closeCallback + }, + unregister(ownerId) { + delete owners[ownerId] + for (let i = stack.length - 1; i >= 0; i--) { + if (stack[i].ownerId === ownerId) stack.splice(i, 1) + } + }, + open(ownerId, popupId) { + if (!stack.some((item) => item.ownerId === ownerId && item.popupId === popupId)) { + pushHistory(ownerId, popupId) + } + }, + close(ownerId, popupId) { + const top = stack[stack.length - 1] + if (top && top.ownerId === ownerId && top.popupId === popupId) { + window.history.back() + } else if (owners[ownerId]) { + owners[ownerId](popupId) + } + }, + clear(ownerId) { + for (let i = stack.length - 1; i >= 0; i--) { + if (stack[i].ownerId === ownerId) stack.splice(i, 1) + } + }, + hasOpen(ownerId) { + return stack.some((item) => item.ownerId === ownerId) + }, + isHandledPopstate(event) { + return Boolean(event && event[popupHandledKey]) + } + } +} diff --git a/src/main/resources/static/components/module/trainSignUp/TrainSignDynamicForm.vue b/src/main/resources/static/components/module/trainSignUp/TrainSignDynamicForm.vue new file mode 100644 index 0000000..8564dfa --- /dev/null +++ b/src/main/resources/static/components/module/trainSignUp/TrainSignDynamicForm.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/src/main/resources/static/components/plugins/mapContainer/TMap.vue b/src/main/resources/static/components/plugins/mapContainer/TMap.vue index 9476b45..f20afe7 100644 --- a/src/main/resources/static/components/plugins/mapContainer/TMap.vue +++ b/src/main/resources/static/components/plugins/mapContainer/TMap.vue @@ -1,6 +1,7 @@ + @@ -76,7 +77,7 @@ src="https://webapi.amap.com/maps?v=2.0&key=57f0d098ba1b881ecc436c4cfd23bbbf&plugin=AMap.PolyEditor,AMap.Geolocation" >--> - + --> - + - diff --git a/src/main/resources/views/platform/zhghh5/welfare/select/index.html b/src/main/resources/views/platform/zhghh5/welfare/select/index.html index 6c561fb..f411cad 100644 --- a/src/main/resources/views/platform/zhghh5/welfare/select/index.html +++ b/src/main/resources/views/platform/zhghh5/welfare/select/index.html @@ -255,24 +255,102 @@ layout("/layouts/platform_h5.html"){ } .welfare-detail-popup { - padding: 20px; - max-height: 70vh; - overflow-y: auto; - min-height: 300px; + height: 82vh; + padding: 16px 12px 12px; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: hidden; } .welfare-detail-title { font-size: 18px; font-weight: bold; - margin-bottom: 15px; + margin: 0 40px 10px; color: var(--text-primary); text-align: center; + flex-shrink: 0; + } + + .welfare-detail-toolbar { + height: 36px; + margin-bottom: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-shrink: 0; + } + + .welfare-detail-toolbar .van-button { + width: 36px; + height: 32px; + padding: 0; + } + + .welfare-detail-scale { + width: 58px; + color: var(--text-secondary); + font-size: 13px; + text-align: center; + } + + .welfare-detail-viewport { + flex: 1; + min-height: 0; + overflow: auto; + border: 1px solid var(--border-color); + background: #fff; + -webkit-overflow-scrolling: touch; + touch-action: pan-x pan-y; + } + + .welfare-detail-stage { + position: relative; + min-width: 100%; + min-height: 100%; } .welfare-detail-content { + position: absolute; + top: 0; + left: 0; + min-width: 100%; + padding: 8px; + box-sizing: border-box; font-size: 14px; line-height: 1.6; color: var(--text-secondary); + transform-origin: 0 0; + } + + .welfare-detail-content table { + width: max-content !important; + min-width: 860px !important; + table-layout: auto !important; + } + + .welfare-detail-content td, + .welfare-detail-content th { + min-width: 70px; + box-sizing: border-box; + } + + .welfare-detail-content td img { + width: 140px !important; + max-width: 180px !important; + max-height: 220px !important; + height: auto !important; + object-fit: contain; + margin: 6px auto !important; + } + + .welfare-detail-content > img, + .welfare-detail-content > p > img, + .welfare-detail-content > div > p > img { + width: auto; + max-width: 100% !important; + height: auto !important; } .welfare-tag { @@ -545,7 +623,7 @@ layout("/layouts/platform_h5.html"){
- @@ -770,10 +848,35 @@ layout("/layouts/platform_h5.html"){ + :close-on-click-overlay="false" + @click-close-icon="closeOptionDetail" + @closed="resetDetailZoom" + :style="{ height: '82vh' }">
{{ selectedOption.optionName }}
-
+
+ + {{Math.round(detailScale * 100)}}% + + +
+
+
+
+
+
@@ -830,6 +933,14 @@ layout("/layouts/platform_h5.html"){ hasSubmittedBefore: false, // 是否之前提交过 showOptionDetailDialog: false, // 选项详情弹窗 selectedOption: null, // 当前选中的选项 + popupHistoryOwner: "welfare-select-" + Date.now(), + detailScale: 1, + detailMinScale: 0.5, + detailMaxScale: 3, + detailContentWidth: 0, + detailContentHeight: 0, + detailPinchStartDistance: 0, + detailPinchStartScale: 1, mobileError: false, // 手机号错误标记 userNameError: false, // 收货人错误标记 formData: { @@ -890,6 +1001,20 @@ layout("/layouts/platform_h5.html"){ const diffHours = (endTime - now) / (1000 * 60 * 60) return diffHours > 0 && diffHours < 24 + }, + + detailStageStyle() { + return { + width: Math.ceil(this.detailContentWidth * this.detailScale) + "px", + height: Math.ceil(this.detailContentHeight * this.detailScale) + "px" + } + }, + + detailContentStyle() { + return { + width: this.detailContentWidth > 0 ? this.detailContentWidth + "px" : "100%", + transform: "scale(" + this.detailScale + ")" + } } }, @@ -1100,6 +1225,7 @@ layout("/layouts/platform_h5.html"){ if (res.code === 0) { this.$toast.success("选择成功") setTimeout(() => { + this.clearPopupHistory() this.$pjaxReplace("/platform/h5/welfare/userSelect/success") }, 1500) } else { @@ -1195,7 +1321,8 @@ layout("/layouts/platform_h5.html"){ this.showConfirmDialog = true }, - async openChoose(row) { + openChoose(row) { + this.clearPopupHistory() this.$pjaxReplace("/platform/h5/welfare/userChoose/index?id=" + row.id) }, @@ -1221,10 +1348,127 @@ layout("/layouts/platform_h5.html"){ // 显示选项详情 showOptionDetail(option) { - // 阻止事件冒泡,避免触发父元素的点击事件 - event.stopPropagation() this.selectedOption = option this.showOptionDetailDialog = true + this.detailScale = 1 + this.detailContentWidth = 0 + this.detailContentHeight = 0 + this.$nextTick(() => { + this.measureDetailContent() + }) + if (window.mobilePopupHistory) { + window.mobilePopupHistory.open(this.popupHistoryOwner, "optionDetail") + } + }, + + closeOptionDetail() { + if (window.mobilePopupHistory) { + window.mobilePopupHistory.close(this.popupHistoryOwner, "optionDetail") + } else { + this.showOptionDetailDialog = false + } + }, + + closePopupFromHistory(popupId) { + if (popupId === "optionDetail") { + this.showOptionDetailDialog = false + } + }, + + handlePageBack() { + if (window.mobilePopupHistory && window.mobilePopupHistory.hasOpen(this.popupHistoryOwner)) { + window.history.back() + return + } + this.historyBack() + }, + + clearPopupHistory() { + if (window.mobilePopupHistory) { + window.mobilePopupHistory.clear(this.popupHistoryOwner) + } + }, + + measureDetailContent() { + this.$nextTick(() => { + const viewport = this.$refs.detailViewport + const content = this.$refs.detailContent + if (!viewport || !content) return + this.detailContentWidth = Math.max(viewport.clientWidth, content.scrollWidth) + this.detailContentHeight = Math.max(viewport.clientHeight, content.scrollHeight) + Array.from(content.querySelectorAll("img")).forEach((image) => { + if (!image.complete) { + image.addEventListener("load", () => { + this.measureDetailContent() + }, {once: true}) + } + }) + }) + }, + + resetDetailZoom() { + this.detailScale = 1 + this.detailPinchStartDistance = 0 + this.$nextTick(() => { + this.measureDetailContent() + const viewport = this.$refs.detailViewport + if (viewport) { + viewport.scrollLeft = 0 + viewport.scrollTop = 0 + } + }) + }, + + changeDetailScale(delta) { + const viewport = this.$refs.detailViewport + if (!viewport) return + this.setDetailScale(this.detailScale + delta, viewport.clientWidth / 2, viewport.clientHeight / 2) + }, + + setDetailScale(scale, centerX, centerY) { + const viewport = this.$refs.detailViewport + if (!viewport) return + const oldScale = this.detailScale + const newScale = Math.min(this.detailMaxScale, Math.max(this.detailMinScale, scale)) + if (Math.abs(newScale - oldScale) < 0.001) return + const contentX = (viewport.scrollLeft + centerX) / oldScale + const contentY = (viewport.scrollTop + centerY) / oldScale + this.detailScale = newScale + this.$nextTick(() => { + viewport.scrollLeft = Math.max(0, contentX * newScale - centerX) + viewport.scrollTop = Math.max(0, contentY * newScale - centerY) + }) + }, + + getTouchDistance(touches) { + const deltaX = touches[0].clientX - touches[1].clientX + const deltaY = touches[0].clientY - touches[1].clientY + return Math.sqrt(deltaX * deltaX + deltaY * deltaY) + }, + + onDetailTouchStart(event) { + if (event.touches.length !== 2) return + this.detailPinchStartDistance = this.getTouchDistance(event.touches) + this.detailPinchStartScale = this.detailScale + }, + + onDetailTouchMove(event) { + if (event.touches.length !== 2 || this.detailPinchStartDistance <= 0) return + event.preventDefault() + const viewport = this.$refs.detailViewport + const rect = viewport.getBoundingClientRect() + const centerX = (event.touches[0].clientX + event.touches[1].clientX) / 2 - rect.left + const centerY = (event.touches[0].clientY + event.touches[1].clientY) / 2 - rect.top + const distance = this.getTouchDistance(event.touches) + this.setDetailScale(this.detailPinchStartScale * distance / this.detailPinchStartDistance, + centerX, centerY) + }, + + onDetailTouchEnd(event) { + if (event.touches.length < 2) { + this.detailPinchStartDistance = 0 + this.detailPinchStartScale = this.detailScale + } }, // 重置签名 @@ -1247,6 +1491,7 @@ layout("/layouts/platform_h5.html"){ // 跳转到地址管理页面 goToAddressManage() { + this.clearPopupHistory() this.$pjaxReplace("/platform/h5/welfare/address/list") }, @@ -1291,11 +1536,12 @@ layout("/layouts/platform_h5.html"){ }, // 获取收货地址 - async getAddress() { - const resp = await this.$axios.post("/platform/welfare/addressManage/selectUserAddress") - if (resp.code === 0) { - this.addressOptions = resp.data - } + getAddress() { + this.$axios.post("/platform/welfare/addressManage/selectUserAddress").then((resp) => { + if (resp.code === 0) { + this.addressOptions = resp.data + } + }) } }, @@ -1337,21 +1583,34 @@ layout("/layouts/platform_h5.html"){ } }, - async created() { + created() { + if (window.mobilePopupHistory) { + window.mobilePopupHistory.register(this.popupHistoryOwner, (popupId) => { + this.closePopupFromHistory(popupId) + }) + } this.projectId = GetQueryString("id") if (this.projectId) { - // 先获取字典数据 - this.welfareProvideMode = await this.$businessTool.getDictOptions("welfareProvideMode") - this.welfareSignMode = await this.$businessTool.getDictOptions("welfareSignMode") - - // 然后获取项目信息和用户选择 - this.getProjectInfo() + Promise.all([ + this.$businessTool.getDictOptions("welfareProvideMode"), + this.$businessTool.getDictOptions("welfareSignMode") + ]).then((results) => { + this.welfareProvideMode = results[0] + this.welfareSignMode = results[1] + this.getProjectInfo() + }) } else { this.$toast.fail("参数错误") setTimeout(() => { this.historyBack() }, 1500) } + }, + + beforeDestroy() { + if (window.mobilePopupHistory) { + window.mobilePopupHistory.unregister(this.popupHistoryOwner) + } } })