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 a539718e..b0db4c57 100644 --- a/src/main/java/com/budwk/app/base/constant/RedisConstant.java +++ b/src/main/java/com/budwk/app/base/constant/RedisConstant.java @@ -48,6 +48,13 @@ public class RedisConstant { */ public static final String UCENTER_SMSCODE = PLATFORM_REDIS_PREFIX + "ucenter:smscode:"; + /** 拼图挑战与凭证按匿名会话隔离,频率键单独存放。 */ + public static final String REDIS_SLIDER_CAPTCHA_KEY = PLATFORM_REDIS_PREFIX + "login:slider:"; + public static final String REDIS_SLIDER_RATE_KEY = PLATFORM_REDIS_PREFIX + "login:slider-rate:"; + + /** 登录短信专用缓存前缀,避免验证码与其他业务场景交叉使用。 */ + public static final String LOGIN_SMSCODE = PLATFORM_REDIS_PREFIX + "login:sms:"; + /** * 接口重复提交前缀 */ diff --git a/src/main/java/com/budwk/app/base/enums/LoginType.java b/src/main/java/com/budwk/app/base/enums/LoginType.java index 21c4f0df..1e3125ea 100644 --- a/src/main/java/com/budwk/app/base/enums/LoginType.java +++ b/src/main/java/com/budwk/app/base/enums/LoginType.java @@ -13,6 +13,7 @@ public enum LoginType { QI_YE_WECHAT("企业微信"), WECHAT("微信"), PC_LOCAL("PC本地"), + SMS_CODE("手机验证码"), MOBILE_LOCAL("手机本地"), CAS("CAS"); diff --git a/src/main/java/com/budwk/app/sys/controller/SysConfController.java b/src/main/java/com/budwk/app/sys/controller/SysConfController.java index 409d1925..d56a0794 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysConfController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysConfController.java @@ -7,10 +7,12 @@ import com.budwk.app.base.result.Result; import com.budwk.app.sys.models.Sys_config; import com.budwk.app.sys.services.SysConfigService; import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.integration.jedis.pubsub.PubSubService; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Strings; @@ -139,12 +141,15 @@ public class SysConfController { @At @Ok("json:full") @SaCheckPermission("sys.manager.conf") + @Aop(TransAop.READ_COMMITTED) public Object data(@Param("pageNumber") int pageNumber, @Param("pageSize") int pageSize, @Param("pageOrderName") String pageOrderName, @Param("pageOrderBy") String pageOrderBy, @Param("configKey") String configKey) { try { + // 由 service 补充默认关闭的入口参数,管理员可通过现有“是/否”控件修改。 + sysConfigService.ensureSmsLoginVisibleConfig(SecurityUtil.getUserId()); ensureAppImageConfig("AppHomeImg", "PC首页轮播图"); ensureAppImageConfig("H5AppHomeImg", "移动端首页轮播图"); ensureAppImageConfig("AppFeaturedActivityImg", "精彩活动页顶部图片"); diff --git a/src/main/java/com/budwk/app/sys/controller/SysLoginController.java b/src/main/java/com/budwk/app/sys/controller/SysLoginController.java index 9df02a29..b6e9698a 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysLoginController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysLoginController.java @@ -16,16 +16,20 @@ import com.budwk.app.base.utils.RsaUtils; import com.budwk.app.sys.models.Sys_log; import com.budwk.app.sys.models.Sys_user; import com.budwk.app.sys.services.SysUserService; +import com.budwk.app.sys.services.SysConfigService; import com.budwk.app.web.commons.auth.service.ValidateService; +import com.budwk.app.zhgh.dayofficework.message.service.SliderCaptchaService; import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.web.commons.base.Globals; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.jasig.cas.client.util.AbstractCasFilter; import org.jasig.cas.client.validation.Assertion; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.integration.jedis.RedisService; import org.nutz.ioc.impl.PropertiesProxy; +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; @@ -35,6 +39,7 @@ import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Filters; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; +import org.nutz.mvc.annotation.POST; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -55,19 +60,19 @@ import java.util.concurrent.ConcurrentHashMap; public class SysLoginController { private static final Log log = Logs.get(); private static final LoginRsaKeyRing LOGIN_RSA_KEY_RING = new LoginRsaKeyRing(); - /** - * TODO: 临时屏蔽回环地址及指定局域网 IP 的平台登录验证码,全部恢复时改为 false。 - */ - private static final boolean DISABLE_LOGIN_CAPTCHA = true; @Inject private SysUserService sysUserService; @Inject + private SysConfigService sysConfigService; + @Inject private RedisService redisService; @Inject private SLogService sLogService; @Inject private ValidateService validateService; @Inject + private SliderCaptchaService sliderCaptchaService; + @Inject protected PropertiesProxy conf; @At("") @@ -75,13 +80,17 @@ public class SysLoginController { @ApiOperation("用户本地登录页面") @Filters public String login(HttpServletRequest req, HttpSession session) { + // 只向模板提供入口显示状态,短信发送及验证码登录接口继续使用原有规则。 + req.setAttribute("smsLoginVisible", sysConfigService.isSmsLoginVisible()); return "beetl:/platform/sys/login.html"; } @At @Ok("re") @Filters - public String h5() { + public String h5(HttpServletRequest req) { + // 手机端复用同一个登录模板,读取相同的系统参数。 + req.setAttribute("smsLoginVisible", sysConfigService.isSmsLoginVisible()); return "beetl:/platform/sys/login.html"; } @@ -93,6 +102,13 @@ public class SysLoginController { } + /** + * 账号密码登录:username 为账号,password 为 RSA 密文,keyId 为公钥编号; + * platformKey/platformCaptcha 为当前会话、当前账号的滑块一次性凭证。 + * 返回 Result:code=0 表示登录成功,msg 为成功提示或失败原因。 + */ + @POST + @Aop(TransAop.READ_COMMITTED) @At("/doLogin") @Ok("json") @ApiOperation("用户本地账号密码登录") @@ -124,14 +140,9 @@ public class SysLoginController { // return Result.error("登录失败次数过多,请5分钟后再试"); // } try { - // 验证码校验 - if (!isLoginCaptchaDisabled(req)) { - try { - validateService.checkCode(captchaKey, captchaCode); - } catch (BaseException e) { - return Result.error(e.getMessage()); - } - } + // 所有访问地址均需消费绑定当前账号的滑块凭证,旧图形码不能用于登录。 + sliderCaptchaService.consume(session.getId(), captchaKey, captchaCode, + SliderCaptchaService.PASSWORD, username); // 解密密码 LoginRsaKey rsaKey = LOGIN_RSA_KEY_RING.getPrivateKey(keyId); @@ -162,19 +173,6 @@ public class SysLoginController { } } - private boolean isLoginCaptchaDisabled(HttpServletRequest req) { - if (!DISABLE_LOGIN_CAPTCHA || req == null) { - return false; - } - String serverName = req.getServerName(); - // 临时允许该局域网地址免验证码;恢复 IP 验证码时删除此条件,并同步删除登录页中的该 IP。 - return "localhost".equalsIgnoreCase(serverName) - || "192.168.0.108".equals(serverName) - || "127.0.0.1".equals(serverName) - || "0:0:0:0:0:0:0:1".equals(serverName) - || "::1".equals(serverName); - } - @At(value = "/platform/sso/login", top = true) @Ok("re") @ApiOperation("用户cas登录统一入口") @@ -277,6 +275,120 @@ public class SysLoginController { } } + /** + * 发送系统绑定手机号的登录验证码,滑块凭证和账号可用性由 service 再次校验。 + * + * @param mobile 已绑定系统账号的 11 位大陆手机号 + * @param captchaKey /checkSliderCaptcha 返回的 platformKey + * @param captchaCode /checkSliderCaptcha 返回的一次性 platformCaptcha + * @param request 当前请求,供后端按连接来源限流 + * @return Result,成功 data 包含 retryAfter 重发等待秒数、expireIn 验证码有效秒数;失败 msg 为原因 + */ + @At("/mobileCaptcha") + @POST + @Ok("json") + @Aop(TransAop.READ_COMMITTED) + @ApiOperation("发送手机登录验证码") + public Object mobileCaptcha(@Param("mobile") String mobile, + @Param("platformKey") String captchaKey, + @Param("platformCaptcha") String captchaCode, + HttpServletRequest request) { + if (StrUtil.isBlank(mobile) || !mobile.trim().matches("^1[3-9]\\d{9}$")) { + return Result.error("请输入正确的手机号码"); + } + if (StrUtil.isBlank(captchaKey) || StrUtil.isBlank(captchaCode)) { + return Result.error("请先完成滑块验证"); + } + try { + return Result.success("短信验证码已发送", sysUserService.sendLoginSmsCode(mobile.trim(), captchaKey, captchaCode, request)); + } catch (BaseException e) { + return Result.error(e.getMessage()); + } catch (Exception e) { + log.error("发送登录短信验证码失败", e); + return Result.error("短信发送失败,请稍后重试或联系管理员"); + } + } + + /** + * 使用与手机号绑定的一次性短信验证码登录,不接收密码或 RSA 公钥参数。 + * + * @param mobile 获取短信时填写的 11 位大陆手机号 + * @param smsCode 用户收到的六位数字验证码 + * @param request 当前登录请求,供 service 创建原有 Sa-Token 会话和登录日志 + * @return Result,code 为 0 表示完成登录;失败 msg 为校验或登录失败原因 + */ + @At("/doSmsLogin") + @POST + @Ok("json") + @Aop(TransAop.READ_COMMITTED) + @ApiOperation("手机验证码登录") + public Object doSmsLogin(@Param("mobile") String mobile, @Param("smsCode") String smsCode, + HttpServletRequest request) { + if (StrUtil.isBlank(mobile) || !mobile.trim().matches("^1[3-9]\\d{9}$")) { + return Result.error("请输入正确的手机号码"); + } + if (StrUtil.isBlank(smsCode) || !smsCode.trim().matches("^\\d{6}$")) { + return Result.error("请输入六位短信验证码"); + } + try { + sysUserService.loginBySmsCode(mobile.trim(), smsCode.trim(), request); + return Result.success("login.success"); + } catch (BaseException e) { + return Result.error(e.getMessage()); + } catch (Exception e) { + log.error("手机验证码登录失败", e); + return Result.error("登录失败,请稍后重试或联系管理员"); + } + } + + /** + * scene 传 password(账号登录)或 sms(发送短信),subject 传对应账号或手机号。 + * 返回 Result.data:challengeId、background/piece 图片、width/height/pieceSize/pieceY 像素信息及 expiresIn 秒数。 + * 会话 ID 仅从服务器读取,业务校验、图片生成和频控均由 service 完成。 + */ + @At("/sliderCaptcha") + @POST + @Ok("json") + @Aop(TransAop.READ_COMMITTED) + public Object sliderCaptcha(@Param("scene") String scene, @Param("subject") String subject, + HttpServletRequest request, HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store"); + try { + return Result.success(sliderCaptchaService.create(request.getSession().getId(), + request.getRemoteAddr(), scene, subject)); + } catch (BaseException e) { + return Result.error(e.getMessage()); + } catch (Exception e) { + log.error("生成滑块验证码失败", e); + return Result.error("验证码加载失败,请稍后重试"); + } + } + + /** + * challengeId 传挑战编号,offset 传拼图左侧在原始 320px 图片中的横坐标。 + * 返回 Result.data:platformKey、platformCaptcha 为一次性凭证,expiresIn 为有效秒数。 + */ + @At("/checkSliderCaptcha") + @POST + @Ok("json") + @Aop(TransAop.READ_COMMITTED) + public Object checkSliderCaptcha(@Param("challengeId") String challengeId, @Param("offset") String offset, + HttpServletRequest request, HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store"); + if (offset == null || !offset.matches("[0-9]{1,3}(\\.[0-9]{1,3})?")) { + return Result.error("滑块位置不正确,请重新验证"); + } + try { + return Result.success(sliderCaptchaService.verify(request.getSession().getId(), + challengeId, Double.parseDouble(offset))); + } catch (BaseException e) { + return Result.error(e.getMessage()); + } catch (Exception e) { + log.error("校验滑块验证码失败", e); + return Result.error("验证失败,请稍后重试"); + } + } + @At("/captcha") @Ok("json") @ApiOperation("获取验证码") diff --git a/src/main/java/com/budwk/app/sys/services/SysConfigService.java b/src/main/java/com/budwk/app/sys/services/SysConfigService.java index 04490ba7..f23ffe55 100644 --- a/src/main/java/com/budwk/app/sys/services/SysConfigService.java +++ b/src/main/java/com/budwk/app/sys/services/SysConfigService.java @@ -10,6 +10,23 @@ import java.util.List; * Created by wizzer on 2016/12/23. */ public interface SysConfigService extends BaseService { + /** 仅控制登录页面短信入口的可见性,不作为后端短信接口的访问开关。 */ + String SMS_LOGIN_VISIBLE_KEY = "AppSmsLoginEnabled"; + + /** + * 补充缺失的短信登录入口参数,初始值为 false,已有管理员配置保持不变。 + * + * @param createdBy 当前打开系统参数列表的管理员 ID,用于记录参数创建人 + */ + void ensureSmsLoginVisibleConfig(String createdBy); + + /** + * 读取短信登录入口显示配置,不依赖异步更新的全局缓存。 + * + * @return 仅参数值为 true 时返回 true;参数缺失、为空或非法时返回 false + */ + boolean isSmsLoginVisible(); + /** * 查询所有数据 * @return diff --git a/src/main/java/com/budwk/app/sys/services/SysUserService.java b/src/main/java/com/budwk/app/sys/services/SysUserService.java index 62746a26..c8a79625 100644 --- a/src/main/java/com/budwk/app/sys/services/SysUserService.java +++ b/src/main/java/com/budwk/app/sys/services/SysUserService.java @@ -8,6 +8,7 @@ import com.budwk.app.base.service.BaseService; import com.budwk.app.sys.models.Sys_menu; import com.budwk.app.sys.models.Sys_role; import com.budwk.app.sys.models.Sys_user; +import org.nutz.lang.util.NutMap; import javax.servlet.http.HttpServletRequest; import java.util.List; @@ -155,14 +156,37 @@ public interface SysUserService extends BaseService { Sys_user loginByPassword(String loginname, String passowrd) throws BaseException; /** - * 通过短信验证码登录 + * 根据手机号获取唯一可登录用户,并加载所属单位及分工会。 * - * @param mobile 手机号 - * @return - * @throws BaseException + * @param mobile 已在系统人员资料中登记的 11 位大陆手机号,允许首尾空格 + * @return Sys_user,包含用户基本资料、unit 单位及 union 分工会关联 + * @throws BaseException 手机号格式不正确、未绑定用户、关联多条用户或账号已禁用 */ Sys_user loginByMobile(String mobile) throws BaseException; + /** + * 为已登记且唯一有效的用户发送登录短信验证码。 + * + * @param mobile 系统人员资料中登记的 11 位大陆手机号 + * @param captchaKey 滑块验证接口返回的 platformKey + * @param captchaCode 滑块验证接口返回的一次性 platformCaptcha 凭证 + * @param request 当前 HTTP 请求,用于取得服务端接收到的客户端 IP 并限制发送频率 + * @return NutMap,retryAfter 为再次发送前应等待的秒数,expireIn 为短信验证码有效秒数 + * @throws BaseException 用户不可登录、滑块凭证错误、请求过于频繁或短信发送失败 + */ + NutMap sendLoginSmsCode(String mobile, String captchaKey, String captchaCode, HttpServletRequest request); + + /** + * 校验登录短信验证码并建立与账号密码登录一致的 Sa-Token 会话。 + * + * @param mobile 系统人员资料中登记的 11 位大陆手机号 + * @param smsCode 发送到该手机号的登录短信验证码 + * @param request 当前 HTTP 请求,供既有登录流程记录 IP 及取得跳转参数 + * @return String,登录成功后应访问的项目页面地址 + * @throws BaseException 用户不可登录、关联资料缺失或短信验证码校验失败 + */ + String loginBySmsCode(String mobile, String smsCode, HttpServletRequest request); + /** * 通过用户名获取用户信息 * @param loginname diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysConfigServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysConfigServiceImpl.java index d586bf68..3e16786b 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysConfigServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysConfigServiceImpl.java @@ -6,8 +6,11 @@ import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.utils.PageUtil; import com.budwk.app.sys.models.Sys_config; import com.budwk.app.sys.services.SysConfigService; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; +import org.nutz.dao.DaoException; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Lang; import org.nutz.lang.Strings; @@ -24,6 +27,35 @@ public class SysConfigServiceImpl extends BaseServiceImpl implements super(dao); } + /** {@inheritDoc} */ + @Override + @Aop(TransAop.READ_COMMITTED) + public synchronized void ensureSmsLoginVisibleConfig(String createdBy) { + if (fetch(SMS_LOGIN_VISIBLE_KEY) != null) { + return; + } + Sys_config config = new Sys_config(); + config.setConfigKey(SMS_LOGIN_VISIBLE_KEY); + config.setConfigValue("false"); + config.setNote("是否开启短信登录(仅控制登录页入口显示)"); + config.setCreatedBy(createdBy); + try { + insert(config); + } catch (DaoException e) { + // 多实例同时首次打开列表时,另一个实例可能已插入;已有值不能被默认值覆盖。 + if (fetch(SMS_LOGIN_VISIBLE_KEY) == null) { + throw e; + } + } + } + + /** {@inheritDoc} */ + @Override + public boolean isSmsLoginVisible() { + Sys_config config = fetch(SMS_LOGIN_VISIBLE_KEY); + return config != null && "true".equals(Strings.trim(config.getConfigValue())); + } + public List getAllList() { return this.query(Cnd.where("delFlag", "=", false)); } diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java index e4141107..5a4e176d 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java @@ -19,6 +19,7 @@ import com.budwk.app.sys.services.SysMenuService; import com.budwk.app.sys.services.SysRoleService; import com.budwk.app.sys.services.SysUserService; import com.budwk.app.sys.views.View_user; +import com.budwk.app.web.commons.auth.service.ValidateService; import com.budwk.app.web.commons.base.Globals; import com.budwk.app.zhgh.staffmanage.specialstaff.model.SpecialStaff; import org.nutz.aop.interceptor.ioc.TransAop; @@ -34,6 +35,7 @@ import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Lang; import org.nutz.lang.Strings; import org.nutz.lang.random.R; +import org.nutz.lang.util.NutMap; import org.nutz.plugins.wkcache.annotation.CacheDefaults; import org.nutz.plugins.wkcache.annotation.CacheRemove; import org.nutz.plugins.wkcache.annotation.CacheRemoveAll; @@ -63,6 +65,8 @@ public class SysUserServiceImpl extends BaseServiceImpl implements Sys private SLogService sLogService; @Inject private RedisService redisService; + @Inject + private ValidateService validateService; @Override @CacheResult(cacheKey = "${userId}_getPermissionList") @@ -368,16 +372,90 @@ public class SysUserServiceImpl extends BaseServiceImpl implements Sys return user; } + /** + * 按登记手机号确认唯一且未禁用的账号,补齐后续登录会话使用的组织关系。 + * + * @param mobile 已登记的 11 位大陆手机号,查询前移除首尾空格 + * @return Sys_user,包含 unit 单位和按单位关联的 union 分工会信息 + * @throws BaseException 手机号无效、未登记、关联多条用户或账号已禁用 + */ @Override public Sys_user loginByMobile(String mobile) throws BaseException { - Sys_user user = this.fetch(Cnd.where("mobile", "=", mobile)); - if (user == null) { - throw new BaseException("用户不存在"); + String normalizedMobile = Strings.sNull(mobile).trim(); + if (!normalizedMobile.matches("^1[3-9]\\d{9}$")) { + throw new BaseException("请输入正确的手机号码"); + } + // 手机号不是唯一字段;任何重复关联都拒绝登录,避免任取账号或绕过禁用状态。 + List users = this.query(Cnd.where("mobile", "=", normalizedMobile)); + if (users.isEmpty()) { + throw new BaseException("该手机号未绑定系统账号,请联系管理员"); + } + if (users.size() > 1) { + throw new BaseException("该手机号关联多个账号,请联系管理员"); + } + Sys_user user = users.get(0); + if (user.isDisabled()) { + throw new BaseException("用户被禁用"); } user = this.fetchLinks(user, "unit"); + if (Lang.isNotEmpty(user.getUnit()) && StrUtil.isNotBlank(user.getUnit().getUnionId())) { + user.setUnion(dao().fetch(Sys_union.class, user.getUnit().getUnionId())); + } return user; } + /** + * 先确认手机号唯一且账号可用,再交由验证码服务校验滑块凭证、限流并实际发送短信。 + * + * @param mobile 系统人员资料中登记的 11 位大陆手机号 + * @param captchaKey 滑块验证接口返回的 platformKey + * @param captchaCode 滑块验证接口返回的一次性 platformCaptcha 凭证 + * @param request 当前请求,以 getRemoteAddr 取得的地址作为服务端发送限流依据 + * @return NutMap,retryAfter 为重发等待秒数,expireIn 为短信验证码有效秒数 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public NutMap sendLoginSmsCode(String mobile, String captchaKey, String captchaCode, HttpServletRequest request) { + if (request == null) { + throw new BaseException("登录请求无效"); + } + Sys_user user = this.loginByMobile(mobile); + return validateService.sendLoginSmsCode(user.getMobile(), captchaKey, captchaCode, request.getRemoteAddr(), request.getSession().getId()); + } + + /** + * 使用短信验证码登录既有账号,验证成功后复用原有会话、日志和跳转处理。 + * + * @param mobile 已登记且只关联一个账号的 11 位大陆手机号 + * @param smsCode 登录短信中的验证码,成功校验后一次性消费 + * @param request 当前请求,供既有登录流程记录客户端 IP 并读取跳转参数 + * @return String,原有登录流程生成的登录成功跳转地址 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public String loginBySmsCode(String mobile, String smsCode, HttpServletRequest request) { + if (request == null) { + throw new BaseException("登录请求无效"); + } + Sys_user user = this.loginByMobile(mobile); + // loginPlus 会读取视图中的工会小组等资料,必须在建立登录态之前确认关联资料存在。 + if (dao().fetch(View_user.class, Cnd.where("id", "=", user.getId())) == null) { + throw new BaseException("账号关联资料不完整,请联系管理员"); + } + validateService.checkLoginSmsCode(user.getMobile(), smsCode); + try { + return this.loginPlus(user, LoginType.SMS_CODE, request); + } catch (RuntimeException e) { + // 仅在进入创建登录态的流程后清理;手机号或验证码校验失败不会注销原有登录。 + try { + StpUtil.logout(); + } catch (RuntimeException cleanupException) { + e.addSuppressed(cleanupException); + } + throw e; + } + } + @Override public Sys_user loginByLoginName(String loginname) { Sys_user user = this.fetch(Cnd.where("loginname", "=", loginname)); @@ -446,6 +524,7 @@ public class SysUserServiceImpl extends BaseServiceImpl implements Sys } @Override + @Aop(TransAop.READ_COMMITTED) public String loginPlus(Sys_user user, LoginType loginType, HttpServletRequest request) { String wxOpenId = request.getParameter("wxOpenId"); diff --git a/src/main/java/com/budwk/app/web/commons/auth/service/ValidateService.java b/src/main/java/com/budwk/app/web/commons/auth/service/ValidateService.java index 40b416a3..02078118 100644 --- a/src/main/java/com/budwk/app/web/commons/auth/service/ValidateService.java +++ b/src/main/java/com/budwk/app/web/commons/auth/service/ValidateService.java @@ -3,19 +3,30 @@ package com.budwk.app.web.commons.auth.service; import com.budwk.app.base.constant.RedisConstant; import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.utils.RsaUtil; +import com.budwk.app.zhgh.dayofficework.message.service.TencentSmsService; +import com.budwk.app.zhgh.dayofficework.message.service.SliderCaptchaService; import com.wf.captcha.ArithmeticCaptcha; import lombok.extern.slf4j.Slf4j; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.integration.jedis.JedisAgent; import org.nutz.integration.jedis.RedisService; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.impl.PropertiesProxy; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Strings; import org.nutz.lang.random.R; import org.nutz.lang.util.NutMap; +import redis.clients.jedis.Jedis; import java.security.KeyFactory; import java.security.PrivateKey; +import java.security.SecureRandom; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; +import java.util.Arrays; +import java.util.Collections; +import java.util.Locale; /** * @author wizzer@qq.com @@ -23,14 +34,45 @@ import java.util.Base64; @IocBean @Slf4j public class ValidateService { + private static final int SMS_EXPIRE_SECONDS = 300; + // 同一手机号的重发等待时间与短信验证码有效期一致,发送成功后五分钟内禁止重发。 + private static final int SMS_RETRY_SECONDS = 300; + private static final SecureRandom SMS_RANDOM = new SecureRandom(); + + // 计数与有效期原子设置,避免并发发送绕过限流或产生永不过期的计数键。 + private static final String HOUR_LIMIT_SCRIPT = "local count=redis.call('incr',KEYS[1]); " + + "if count==1 then redis.call('expire',KEYS[1],3600) end; return count"; + // 成功发送后才保存验证码;带号码 hash tag 的键可在同一个 Redis 分片原子更新。 + private static final String SAVE_SMS_SCRIPT = "if redis.call('get',KEYS[1])~=ARGV[1] then return 0 end; " + + "redis.call('set',KEYS[2],ARGV[2],'EX',ARGV[3]); redis.call('del',KEYS[3]); " + + "redis.call('expire',KEYS[1],ARGV[4]); return 1"; + private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get',KEYS[1])==ARGV[1] " + + "then return redis.call('del',KEYS[1]) else return 0 end"; + // 校验、错误计数和消费必须原子完成,同一码只能有一个请求成功,五次输错后作废。 + private static final String VERIFY_SMS_SCRIPT = "local code=redis.call('get',KEYS[1]); " + + "if not code then return 0 end; if code~=ARGV[1] then " + + "local count=redis.call('incr',KEYS[2]); local ttl=redis.call('ttl',KEYS[1]); " + + "redis.call('expire',KEYS[2],math.max(ttl,1)); " + + "if count>=5 then redis.call('del',KEYS[1],KEYS[2]); return -2 end; return -1 end; " + + "redis.call('del',KEYS[1],KEYS[2]); return 1"; + @Inject private RedisService redisService; + @Inject + private JedisAgent jedisAgent; + @Inject + private PropertiesProxy conf; + @Inject + private TencentSmsService tencentSmsService; + @Inject + private SliderCaptchaService sliderCaptchaService; /** * 获取验证码 * - * @return + * @return key 为图形验证码标识,codeUrl 为可直接展示的 Base64 图片;验证码有效期为三分钟 */ + @Aop(TransAop.READ_COMMITTED) public NutMap getCaptcha() { String uuid = R.UU32(); ArithmeticCaptcha captcha = new ArithmeticCaptcha(120, 40); @@ -41,21 +83,66 @@ public class ValidateService { } /** - * 发送短信验证码 + * 校验发送条件后发送登录短信,只有腾讯云确认发送成功才写入有效验证码。 * - * @param mobile 手机号码 - * @throws BaseException + * @param mobile 已由用户 service 确认唯一绑定、未禁用的 11 位大陆手机号 + * @param captchaKey 滑块验证接口返回的 platformKey + * @param captchaCode 滑块验证接口返回的一次性 platformCaptcha 凭证 + * @param sessionId 服务端当前 Servlet 会话 ID,不接受前端指定 + * @param clientIp 请求连接的来源 IP,用于后端发送频控 + * @return retryAfter 为重发等待秒数,expireIn 为本次短信验证码有效秒数,不返回验证码或 Redis 键 + * @throws BaseException 参数无效、滑块凭证不正确、频控触发或短信发送失败 */ - public void getSmsCode(String mobile) throws BaseException { - String text = R.captchaNumber(4); - String codeFromRedis = redisService.get(RedisConstant.UCENTER_SMSCODE + mobile + ":LOCK"); - if (Strings.isNotBlank(codeFromRedis)) { - throw new BaseException("请1分钟之后再试"); + @Aop(TransAop.READ_COMMITTED) + public NutMap sendLoginSmsCode(String mobile, String captchaKey, String captchaCode, String clientIp, String sessionId) { + if (mobile == null || !mobile.matches("^1[3-9]\\d{9}$") || Strings.isBlank(clientIp)) { + throw new BaseException("手机号或请求来源不正确"); + } + sliderCaptchaService.consume(sessionId, captchaKey, captchaCode, SliderCaptchaService.SMS, mobile); + tencentSmsService.checkLoginConfiguration(); + String mobileKey = RedisConstant.LOGIN_SMSCODE + "{" + mobile + "}"; + String lockKey = mobileKey + ":LOCK"; + String lockToken = R.UU32(); + try (Jedis jedis = jedisAgent.getResource()) { + if (!"OK".equals(jedis.set(lockKey, lockToken, "NX", "EX", SMS_RETRY_SECONDS))) { + throw new BaseException("验证码发送频繁,请5分钟后再试"); + } + } + + boolean saved = false; + try { + try (Jedis jedis = jedisAgent.getResource()) { + long mobileCount = ((Number) jedis.eval(HOUR_LIMIT_SCRIPT, + Collections.singletonList(mobileKey + ":HOUR"), Collections.emptyList())).longValue(); + long ipCount = ((Number) jedis.eval(HOUR_LIMIT_SCRIPT, + Collections.singletonList(RedisConstant.LOGIN_SMSCODE + "ip:" + clientIp), Collections.emptyList())).longValue(); + if (mobileCount > Math.max(1, conf.getInt("sms.login.mobile-hour-limit", 10)) + || ipCount > Math.max(1, conf.getInt("sms.login.ip-hour-limit", 30))) { + throw new BaseException("验证码请求次数过多,请稍后再试"); + } + } + String code = String.format(Locale.ROOT, "%06d", SMS_RANDOM.nextInt(1000000)); + tencentSmsService.sendLoginCode(mobile, code); + try (Jedis jedis = jedisAgent.getResource()) { + Number saveResult = (Number) jedis.eval(SAVE_SMS_SCRIPT, + Arrays.asList(lockKey, mobileKey, mobileKey + ":FAIL"), + Arrays.asList(lockToken, code, String.valueOf(SMS_EXPIRE_SECONDS), String.valueOf(SMS_RETRY_SECONDS))); + if (saveResult.longValue() != 1) { + throw new BaseException("短信验证码处理超时,请稍后重新获取"); + } + saved = true; + } + return NutMap.NEW().addv("retryAfter", SMS_RETRY_SECONDS).addv("expireIn", SMS_EXPIRE_SECONDS); + } finally { + // 失败只释放本请求持有的锁,不覆盖旧验证码,也不删除其他请求的新发送锁。 + if (!saved) { + try (Jedis jedis = jedisAgent.getResource()) { + jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey), Collections.singletonList(lockToken)); + } catch (RuntimeException e) { + log.warn("登录短信发送锁释放失败,锁将在五分钟内自动过期"); + } + } } -// smsSendServer.sendCode(mobile, text); - log.debug("sms code:::" + text); - redisService.setex(RedisConstant.UCENTER_SMSCODE + mobile, 300, text); - redisService.setex(RedisConstant.UCENTER_SMSCODE + mobile + ":LOCK", 60, text); } /** @@ -65,6 +152,7 @@ public class ValidateService { * @param code 验证码的值 * @throws BaseException */ + @Aop(TransAop.READ_COMMITTED) public void checkCode(String key, String code) throws BaseException { String codeFromRedis = redisService.get(RedisConstant.UCENTER_CAPTCHA + key); @@ -81,27 +169,34 @@ public class ValidateService { } /** - * 核验短信验证码 + * 按服务端收到的手机号核验登录短信验证码,成功后原子删除,输错五次作废。 * - * @param mobile 手机号码 - * @param code 验证码值 - * @throws BaseException + * @param mobile 本次登录的 11 位大陆手机号,与发送时的手机号完全一致 + * @param code 用户输入的六位数字短信验证码 + * @throws BaseException 验证码为空、格式错误、不匹配、过期或已使用 */ - public void checkSMSCode(String mobile, String code) throws BaseException { - String codeFromRedis = redisService.get(RedisConstant.UCENTER_SMSCODE + mobile); - - if (Strings.isBlank(code)) { - throw new BaseException("请输入短信验证码"); + @Aop(TransAop.READ_COMMITTED) + public void checkLoginSmsCode(String mobile, String code) throws BaseException { + if (mobile == null || !mobile.matches("^1[3-9]\\d{9}$") || code == null || !code.matches("^\\d{6}$")) { + throw new BaseException("请输入正确的手机号和六位短信验证码"); } - if (Strings.isEmpty(codeFromRedis)) { - throw new BaseException("短信验证码已过期"); + String mobileKey = RedisConstant.LOGIN_SMSCODE + "{" + mobile + "}"; + try (Jedis jedis = jedisAgent.getResource()) { + long result = ((Number) jedis.eval(VERIFY_SMS_SCRIPT, + Arrays.asList(mobileKey, mobileKey + ":FAIL"), Collections.singletonList(code))).longValue(); + if (result == 0) { + throw new BaseException("短信验证码已过期或已使用,请重新获取"); + } + if (result == -2) { + throw new BaseException("短信验证码错误次数过多,请重新获取"); + } + if (result != 1) { + throw new BaseException("短信验证码不正确"); + } } - if (!Strings.equalsIgnoreCase(code, codeFromRedis)) { - throw new BaseException("短信验证码不正确"); - } - redisService.del(RedisConstant.UCENTER_SMSCODE + mobile); } + @Aop(TransAop.READ_COMMITTED) public String decryptPwd(String keyId, String password) throws Exception { // 从 Redis 获取私钥 String rsaKey = RedisConstant.RSA_KEY_PREFIX + keyId; diff --git a/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserClubListController.java b/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserClubListController.java index 610b6f47..6524ede0 100644 --- a/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserClubListController.java +++ b/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserClubListController.java @@ -1,6 +1,7 @@ package com.budwk.app.zhgh.club.controller.apply; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaMode; import cn.hutool.core.util.StrUtil; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.param.PageForm; @@ -47,8 +48,19 @@ public class ClubUserClubListController { public void index() { } + /** + * 打开移动端社团列表,共用电脑端的社团查询及入退会状态。 + * + * @return 无返回值,由模板渲染社团列表页面 + */ + @At("/h5") + @Ok("beetl:/platform/zhghh5/club/list/index.html") + @SaCheckPermission(value = {"h5.club.join.list", "h5.club.join.apply"}, mode = SaMode.OR) + public void h5Index() { + } + @At - @SaCheckPermission("club.join.list") + @SaCheckPermission(value = {"club.join.list", "h5.club.join.list", "h5.club.join.apply"}, mode = SaMode.OR) public Result pageData(@Valid PageForm pageForm, Boolean hasJoin, String clubName, String clubType) { Cnd cnd = Cnd.NEW(); cnd.and("dismiss", "=", false); @@ -71,7 +83,7 @@ public class ClubUserClubListController { } @At - @SaCheckPermission("club.join.list") + @SaCheckPermission(value = {"club.join.list", "h5.club.join.list", "h5.club.join.apply"}, mode = SaMode.OR) public Result getCount() { // 获取所有社团 List list = sysClubService.query(); @@ -96,7 +108,7 @@ public class ClubUserClubListController { } @At - @SaCheckPermission("club.join.list") + @SaCheckPermission(value = {"club.join.list", "h5.club.join.list", "h5.club.join.apply"}, mode = SaMode.OR) public Result findOne(@Valid String id) { ClubRegisterVo clubRegisterVo = sysClubService.findOne(id); return Result.success(clubRegisterVo); diff --git a/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinApplyController.java b/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinApplyController.java index 59d147cd..90629d45 100644 --- a/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinApplyController.java @@ -74,10 +74,22 @@ public class ClubUserJoinApplyController { public void index() { } + /** + * 移动端首次入会先选择社团,已选社团或已有申请继续进入申请表单。 + * + * @param clubId 已选择的社团ID + * @param bizId 已有入会申请ID + * @param taskId 重新提交申请时的流程任务ID + * @return 无申请上下文时跳转社团列表,否则渲染原申请页面 + */ @At("/h5") @SaCheckPermission("h5.club.join.apply") - @Ok("beetl:/platform/zhghh5/club/apply/index.html") - public void h5Index() { + @Ok("re") + public String h5Index(@Param("clubId") String clubId, @Param("bizId") String bizId, @Param("taskId") String taskId) { + if (StrUtil.isBlank(clubId) && StrUtil.isBlank(bizId) && StrUtil.isBlank(taskId)) { + return ">>:/platform/club/join/list/h5"; + } + return "beetl:/platform/zhghh5/club/apply/index.html"; } @At diff --git a/src/main/java/com/budwk/app/zhgh/club/controller/common/ClubCommonController.java b/src/main/java/com/budwk/app/zhgh/club/controller/common/ClubCommonController.java index 860d7e8c..3b9ae892 100644 --- a/src/main/java/com/budwk/app/zhgh/club/controller/common/ClubCommonController.java +++ b/src/main/java/com/budwk/app/zhgh/club/controller/common/ClubCommonController.java @@ -2,12 +2,17 @@ package com.budwk.app.zhgh.club.controller.common; import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaMode; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.StrUtil; import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.result.Result; import com.budwk.app.base.service.BaseService; import com.budwk.app.bpm.enums.BpmProcessInstanceStatusEnum; import com.budwk.app.bpm.enums.BpmProcessTaskStatusEnum; import com.budwk.app.flow.enums.ProcessInstanceStateEnum; +import com.budwk.app.sys.enums.SysFileEngineTypeEnum; +import com.budwk.app.sys.services.SysFileService; import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.zhgh.club.model.ClubUser; @@ -19,10 +24,17 @@ import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.AdaptBy; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.POST; +import org.nutz.mvc.annotation.Param; +import org.nutz.mvc.upload.TempFile; +import org.nutz.mvc.upload.UploadAdaptor; import java.util.List; +import java.util.Locale; +import java.util.Set; @IocBean @At("/platform/club/common") @@ -36,6 +48,36 @@ public class ClubCommonController { @Inject private SysClubService sysClubService; + @Inject + private SysFileService sysFileService; + + /** + * 上传社团缩略图,限制图片大小并复用系统文件服务校验图片内容及保存文件。 + * + * @param file 用户上传的 JPG、PNG 或 GIF 图片,大小不得超过 2MB + * @return 成功时返回图片下载地址,文件无效或超过限制时返回错误提示 + */ + @At + @POST + @AdaptBy(type = UploadAdaptor.class, args = {"ioc:fileUpload"}) + @SaCheckPermission(value = {"club.register.apply", "club.infoManage.manage"}, mode = SaMode.OR) + public Result uploadThumbnail(@Param("file") TempFile file) { + if (file == null || StrUtil.isBlank(file.getSubmittedFileName())) { + return Result.error("请选择社团缩略图"); + } + if (file.getSize() > 2L * 1024 * 1024) { + return Result.error("社团缩略图大小不能超过2MB"); + } + String suffix = FileUtil.extName(file.getSubmittedFileName()).toLowerCase(Locale.ROOT); + if (!Set.of("jpg", "jpeg", "png", "gif").contains(suffix)) { + return Result.error("社团缩略图仅支持JPG、PNG、GIF图片"); + } + try { + return Result.success().addData(sysFileService.uploadReturnUrl(SysFileEngineTypeEnum.MINIO.getValue(), file)); + } catch (Exception e) { + return Result.error(e.getMessage()); + } + } @At @SaCheckLogin diff --git a/src/main/java/com/budwk/app/zhgh/club/model/SysClub.java b/src/main/java/com/budwk/app/zhgh/club/model/SysClub.java index 32a27cc3..2ea042cc 100644 --- a/src/main/java/com/budwk/app/zhgh/club/model/SysClub.java +++ b/src/main/java/com/budwk/app/zhgh/club/model/SysClub.java @@ -37,6 +37,11 @@ public class SysClub extends BaseModel { @NotEmpty(message = "社团名称不能为空") private String clubName; + @Column + @Comment("社团缩略图") + @ColDefine(type = ColType.VARCHAR, width = 1000) + private String thumbnail; + @Column @Comment("社团编码") @ColDefine(type = ColType.VARCHAR, width = 100) diff --git a/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubServiceImpl.java b/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubServiceImpl.java index 598d0265..d2a4bf4a 100644 --- a/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubServiceImpl.java @@ -112,11 +112,14 @@ public class SysClubServiceImpl extends BaseServiceImpl implements SysC if (StrUtil.isBlank(clubId)) { return new ClubRegisterVo(); } + // 与社团列表保持一致,按社团成员角色汇总会长、秘书长姓名,避免多名任职人员导致详情记录重复。 Sql sql = Sqls.create(""" SELECT club.*, d.NAME AS typeName, u.username concatPersonName, + (select GROUP_CONCAT(username) from sys_user where id in (select userId from club_user u where u.clubId=club.id and JSON_CONTAINS(u.roleCode, '"CLUB_PRESIDENT"'))) as clubLeader, + (select GROUP_CONCAT(username) from sys_user where id in (select userId from club_user u where u.clubId=club.id and JSON_CONTAINS(u.roleCode, '"CLUB_SECRETARY"'))) as clubSecretary, ( SELECT count( 1 ) FROM club_user scu WHERE scu.clubId = club.id ) currentPeopleNum, (select GROUP_CONCAT(username) from sys_user where id in (select sponsorId from sys_club_sponsor where clubId = club.id)) as sponsorName, (select GROUP_CONCAT(sponsorId) from sys_club_sponsor where clubId = club.id) as sponsor diff --git a/src/main/java/com/budwk/app/zhgh/club/vo/ClubRegisterVo.java b/src/main/java/com/budwk/app/zhgh/club/vo/ClubRegisterVo.java index 62c40195..e239d79b 100644 --- a/src/main/java/com/budwk/app/zhgh/club/vo/ClubRegisterVo.java +++ b/src/main/java/com/budwk/app/zhgh/club/vo/ClubRegisterVo.java @@ -19,4 +19,10 @@ public class ClubRegisterVo extends SysClub { private String sponsorName; private Integer currentPeopleNum; private String sponsor; + + @ApiModelProperty("会长") + private String clubLeader; + + @ApiModelProperty("秘书长") + private String clubSecretary; } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/h5controller/H5HealthCheckupController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/h5controller/H5HealthCheckupController.java index be0c21cd..63b6821c 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/h5controller/H5HealthCheckupController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/healthCheckup/h5controller/H5HealthCheckupController.java @@ -84,6 +84,7 @@ public class H5HealthCheckupController { Sql sql = Sqls.create(""" select *, + (select count(*) from health_checkup_project_subject subject where subject.projectId=p.id) as subjectCount, (select count(*) from health_checkup_user_selection s where s.projectId=p.id and s.selectUserId=@userId) as selectCount from health_checkup_project p @@ -121,6 +122,8 @@ public class H5HealthCheckupController { p.choiceTimeStart, p.choiceTimeEnd, p.cover, + p.healthCheckupType, + (select count(*) from health_checkup_project_subject subject where subject.projectId=p.id) as subjectCount, sub.optionName FROM health_checkup_user_selection us diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/SliderCaptchaService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/SliderCaptchaService.java new file mode 100644 index 00000000..222f2701 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/SliderCaptchaService.java @@ -0,0 +1,269 @@ +package com.budwk.app.zhgh.dayofficework.message.service; + +import com.budwk.app.base.constant.RedisConstant; +import com.budwk.app.base.exception.BaseException; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.integration.jedis.RedisService; +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.Strings; +import org.nutz.lang.util.NutMap; + +import javax.imageio.ImageIO; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.geom.Area; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.UUID; + +/** + * PC 登录拼图验证码:原图仅在服务端缩放,正确横坐标只保存在 Redis。 + * 数据库事务注解遵循项目规范;Redis 状态转换的原子性由 Lua 保证。 + */ +@IocBean +public class SliderCaptchaService { + public static final String PASSWORD = "password"; + public static final String SMS = "sms"; + private static final int WIDTH = 320; + private static final int HEIGHT = 160; + private static final int PIECE_SIZE = 50; + private static final int CHALLENGE_SECONDS = 180; + private static final int TICKET_SECONDS = 60; + // PC 和手机端共用图片池:0~9 为风景,10~19 为动物,文件位于 static/captcha/slider。 + private static final String[] BACKGROUNDS = { + "0.jpg", "1.jpg", "2.jpg", "3.jpg", "4.jpg", + "5.jpg", "6.jpg", "7.jpg", "8.jpg", "9.jpg", + "10.jpg", "11.jpg", "12.jpg", "13.jpg", "14.jpg", + "15.jpg", "16.jpg", "17.jpg", "18.jpg", "19.jpg" + }; + private final SecureRandom random = new SecureRandom(); + private volatile BufferedImage[] backgrounds; + + @Inject + private RedisService redisService; + + // 计数和首次设置有效期必须同时完成,防止进程中断留下永不过期的限流键。 + private static final String RATE_SCRIPT = "local n = redis.call('INCR', KEYS[1]); " + + "if n == 1 then redis.call('EXPIRE', KEYS[1], 60) end; return n"; + + // 一个挑战仅允许提交一次。旧挑战不能删除刷新后生成的新挑战,已通过状态不能再次换票。 + private static final String VERIFY_SCRIPT = "local raw = redis.call('GET', KEYS[1]); " + + "if not raw then return 0 end; local s = cjson.decode(raw); " + + "if s.id ~= ARGV[1] or s.state ~= 'pending' then return 0 end; " + + "if math.abs(s.x - tonumber(ARGV[2])) > 4 then redis.call('DEL', KEYS[1]); return -1 end; " + + "redis.call('SETEX', KEYS[1], ARGV[4], " + + "cjson.encode({id=s.id,state='passed',ticket=ARGV[3],binding=s.binding})); return 1"; + + // 比对与删除在同一个 Redis 命令中执行,并发登录最多只能消费一次凭证。 + private static final String CONSUME_SCRIPT = "local raw = redis.call('GET', KEYS[1]); " + + "if not raw then return 0 end; local s = cjson.decode(raw); " + + "if s.state ~= 'passed' or s.id ~= ARGV[1] or s.ticket ~= ARGV[2] or s.binding ~= ARGV[3] then return 0 end; " + + "redis.call('DEL', KEYS[1]); return 1"; + + /** + * 创建当前会话的新挑战,覆盖该会话之前的挑战或登录凭证。 + * + * @param sessionId Servlet 生成的当前会话 ID,不接受前端自行指定 + * @param remoteAddress Servlet 连接的客户端 IP,用于独立于会话的请求限流 + * @param scene password 为账号登录,sms 为发送登录短信 + * @param subject password 场景传原始账号,sms 场景传已去除首尾空格的手机号 + * @return NutMap:challengeId 为挑战编号;background/piece 为图像 data URL; + * width/height/pieceSize/pieceY 为原图像素尺寸和拼图纵坐标;expiresIn 为有效秒数 + */ + @Aop(TransAop.READ_COMMITTED) + public NutMap create(String sessionId, String remoteAddress, String scene, String subject) throws IOException { + String binding = binding(scene, subject); + String sessionHash = digest(sessionId); + long ipCount = ((Number) redisService.eval(RATE_SCRIPT, 1, + RedisConstant.REDIS_SLIDER_RATE_KEY + "ip:" + digest(remoteAddress))).longValue(); + long sessionCount = ((Number) redisService.eval(RATE_SCRIPT, 1, + RedisConstant.REDIS_SLIDER_RATE_KEY + "session:" + sessionHash)).longValue(); + if (ipCount > 120 || sessionCount > 20) { + throw new BaseException("获取拼图过于频繁,请稍后再试"); + } + + BufferedImage[] images = getBackgrounds(); + BufferedImage original = images[random.nextInt(images.length)]; + // 左侧预留拖动起点,四周留边,避免拼图越界或用户无需拖动就能通过。 + int x = 60 + random.nextInt(WIDTH - PIECE_SIZE - 70); + int y = 10 + random.nextInt(HEIGHT - PIECE_SIZE - 20); + Area shape = new Area(new Rectangle2D.Double(0, 8, 42, 42)); + shape.add(new Area(new Ellipse2D.Double(13, 0, 16, 16))); + shape.add(new Area(new Ellipse2D.Double(34, 21, 16, 16))); + shape.subtract(new Area(new Ellipse2D.Double(-8, 21, 16, 16))); + + BufferedImage background = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); + Graphics2D bg = background.createGraphics(); + try { + bg.drawImage(original, 0, 0, null); + bg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + bg.translate(x, y); + bg.setColor(new Color(0, 0, 0, 170)); + bg.fill(shape); + bg.setColor(new Color(255, 255, 255, 210)); + bg.setStroke(new BasicStroke(1.5f)); + bg.draw(shape); + } finally { + bg.dispose(); + } + BufferedImage piece = new BufferedImage(PIECE_SIZE, PIECE_SIZE, BufferedImage.TYPE_INT_ARGB); + Graphics2D pg = piece.createGraphics(); + try { + pg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + pg.setClip(shape); + pg.drawImage(original, -x, -y, null); + pg.setClip(null); + pg.setColor(Color.WHITE); + pg.setStroke(new BasicStroke(1.5f)); + pg.draw(shape); + } finally { + pg.dispose(); + } + + String challengeId = newId(); + NutMap response = NutMap.NEW().addv("challengeId", challengeId) + .addv("background", imageUrl(background, "jpg")) + .addv("piece", imageUrl(piece, "png")) + .addv("width", WIDTH).addv("height", HEIGHT) + .addv("pieceSize", PIECE_SIZE).addv("pieceY", y) + .addv("expiresIn", CHALLENGE_SECONDS); + redisService.setex(RedisConstant.REDIS_SLIDER_CAPTCHA_KEY + sessionHash, CHALLENGE_SECONDS, + Json.toJson(NutMap.NEW().addv("id", challengeId).addv("state", "pending").addv("x", x).addv("binding", binding))); + return response; + } + + /** + * 校验拖动位置并签发登录凭证,失败后当前挑战作废。 + * + * @param sessionId 当前 Servlet 会话 ID + * @param challengeId create 返回的 32 位挑战编号 + * @param offset 拼图左边缘在原图中的横坐标,前端需将显示像素换算为原图像素 + * @return NutMap:platformKey 为挑战编号,platformCaptcha 为随机的一次性凭证,expiresIn 为有效秒数 + */ + @Aop(TransAop.READ_COMMITTED) + public NutMap verify(String sessionId, String challengeId, double offset) { + if (!validId(challengeId) || !Double.isFinite(offset) || offset < 0 || offset > WIDTH - PIECE_SIZE) { + throw new BaseException("拼图参数不正确,请重新获取"); + } + String ticket = newId(); + long result = ((Number) redisService.eval(VERIFY_SCRIPT, 1, + RedisConstant.REDIS_SLIDER_CAPTCHA_KEY + digest(sessionId), challengeId, + Double.toString(offset), ticket, Integer.toString(TICKET_SECONDS))).longValue(); + if (result != 1) { + throw new BaseException(result == -1 ? "未对齐缺口,请重新验证" : "拼图已失效,请重新验证"); + } + return NutMap.NEW().addv("platformKey", challengeId).addv("platformCaptcha", ticket) + .addv("expiresIn", TICKET_SECONDS); + } + + /** + * PC 账号认证之前消费拼图凭证。无返回值代表校验通过,失败抛出验证码认证异常。 + * + * @param sessionId 当前 Servlet 会话 ID,保证其他会话不能使用此凭证 + * @param challengeId 登录参数 platformKey,取自 verify 的返回结果 + * @param ticket 登录参数 platformCaptcha,必须为 verify 签发的一次性凭证 + * @param scene 本次操作用途 password 或 sms,不能跨用途使用 + * @param subject 本次操作的账号或手机号,必须与创建挑战时一致 + */ + @Aop(TransAop.READ_COMMITTED) + public void consume(String sessionId, String challengeId, String ticket, String scene, String subject) { + if (Strings.isBlank(challengeId) || Strings.isBlank(ticket)) { + throw new BaseException("请先完成拼图验证"); + } + if (!validId(challengeId) || !validId(ticket)) { + throw new BaseException("拼图验证已失效,请重新验证"); + } + long result = ((Number) redisService.eval(CONSUME_SCRIPT, 1, + RedisConstant.REDIS_SLIDER_CAPTCHA_KEY + digest(sessionId), challengeId, ticket, binding(scene, subject))).longValue(); + if (result != 1) { + throw new BaseException("拼图验证已失效,请重新验证"); + } + } + + /** 将用途与账号绑定到摘要中,防止验证后换号或跨用途消费;不在 Redis 保存账号明文。 */ + private String binding(String scene, String subject) { + if ((!PASSWORD.equals(scene) && !SMS.equals(scene)) || Strings.isBlank(subject) + || subject.length() > 200 || (SMS.equals(scene) && !subject.matches("^1[3-9]\\d{9}$"))) { + throw new BaseException("请先填写正确的账号或手机号"); + } + return digest(scene + ":" + subject); + } + + /** 缩小后的原图只缓存一份且不再修改;使用资源流以兼容 JAR 部署。 */ + private BufferedImage[] getBackgrounds() throws IOException { + if (backgrounds == null) { + synchronized (this) { + if (backgrounds == null) { + BufferedImage[] loaded = new BufferedImage[BACKGROUNDS.length]; + for (int i = 0; i < BACKGROUNDS.length; i++) { + try (InputStream input = SliderCaptchaService.class.getResourceAsStream( + "/static/captcha/slider/" + BACKGROUNDS[i])) { + if (input == null) { + throw new IOException("拼图背景资源缺失:" + BACKGROUNDS[i]); + } + BufferedImage source = ImageIO.read(input); + if (source == null) { + throw new IOException("拼图背景无法读取:" + BACKGROUNDS[i]); + } + loaded[i] = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = loaded[i].createGraphics(); + try { + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BICUBIC); + graphics.drawImage(source, 0, 0, WIDTH, HEIGHT, null); + } finally { + graphics.dispose(); + } + } + } + backgrounds = loaded; + } + } + } + return backgrounds; + } + + private String imageUrl(BufferedImage image, String format) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + if (!ImageIO.write(image, format, output)) { + throw new IOException("不支持的拼图编码:" + format); + } + return "data:image/" + ("jpg".equals(format) ? "jpeg" : format) + ";base64," + + Base64.getEncoder().encodeToString(output.toByteArray()); + } + + private String newId() { + return UUID.randomUUID().toString().replace("-", ""); + } + + private boolean validId(String value) { + return value != null && value.matches("[a-f0-9]{32}"); + } + + /** Redis 键中不暴露原始会话 ID 或 IP,也避免外部字符影响键结构。 */ + private String digest(String value) { + if (Strings.isBlank(value)) { + throw new BaseException("会话已失效,请刷新登录页面"); + } + try { + byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("无法计算拼图会话标识", e); + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/TencentSmsService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/TencentSmsService.java new file mode 100644 index 00000000..b0f75903 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/TencentSmsService.java @@ -0,0 +1,23 @@ +package com.budwk.app.zhgh.dayofficework.message.service; + +/** + * 腾讯云登录验证码短信服务,与校内通讯平台的业务通知短信分别配置。 + */ +public interface TencentSmsService { + + /** + * 校验登录短信开关和腾讯云必填配置,配置缺失时禁止生成有效验证码。 + * + * @throws com.budwk.app.base.exception.BaseException 未启用短信或缺少凭证、签名、应用及模板配置 + */ + void checkLoginConfiguration(); + + /** + * 向单个已绑定账号的大陆手机号发送登录验证码。 + * + * @param mobile 不带 +86 的 11 位大陆手机号,由登录业务层完成账号绑定及停用校验 + * @param code 六位数字验证码,作为登录短信模板的唯一变量 + * @throws com.budwk.app.base.exception.BaseException 配置无效、腾讯云调用失败或目标号码发送状态不为 Ok + */ + void sendLoginCode(String mobile, String code); +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/impl/TencentSmsServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/impl/TencentSmsServiceImpl.java new file mode 100644 index 00000000..1aad1dec --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/message/service/impl/TencentSmsServiceImpl.java @@ -0,0 +1,147 @@ +package com.budwk.app.zhgh.dayofficework.message.service.impl; + +import com.budwk.app.base.exception.BaseException; +import com.budwk.app.zhgh.dayofficework.message.service.TencentSmsService; +import com.tencentcloudapi.common.Credential; +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import com.tencentcloudapi.common.profile.ClientProfile; +import com.tencentcloudapi.common.profile.HttpProfile; +import com.tencentcloudapi.sms.v20190711.SmsClient; +import com.tencentcloudapi.sms.v20190711.models.SendSmsRequest; +import com.tencentcloudapi.sms.v20190711.models.SendSmsResponse; +import com.tencentcloudapi.sms.v20190711.models.SendStatus; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.impl.PropertiesProxy; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.Strings; +import org.nutz.log.Log; +import org.nutz.log.Logs; + +/** + * 沿用参考项目的腾讯云 v20190711 SDK、+86 号码和单变量登录模板。 + */ +@IocBean +public class TencentSmsServiceImpl implements TencentSmsService { + private static final Log log = Logs.get(); + + @Inject + private PropertiesProxy conf; + + /** {@inheritDoc} */ + @Override + public void checkLoginConfiguration() { + if (!conf.getBoolean("sms.enabled", false)) { + throw new BaseException("短信登录暂未启用,请使用账号密码登录"); + } + requiredConfig("sms.tencent.secret-id"); + requiredConfig("sms.tencent.secret-key"); + requiredConfig("sms.tencent.appid"); + requiredConfig("sms.tencent.sign"); + requiredConfig("sms.tencent.tpl.login_code"); + } + + /** {@inheritDoc} */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void sendLoginCode(String mobile, String code) { + if (mobile == null || !mobile.matches("^1[3-9]\\d{9}$") + || code == null || !code.matches("^\\d{6}$")) { + throw new BaseException("短信手机号或验证码格式不正确"); + } + checkLoginConfiguration(); + // 单调时钟统计本次 SDK 调用(含客户端初始化)的耗时,不受系统时间校准影响。 + long startedAt = System.nanoTime(); + try { + Credential credential = new Credential(requiredConfig("sms.tencent.secret-id"), requiredConfig("sms.tencent.secret-key")); + HttpProfile httpProfile = new HttpProfile(); + httpProfile.setEndpoint("sms.tencentcloudapi.com"); + // 超时应小于发送锁有效期,避免慢请求与下一次重发同时写入验证码。 + httpProfile.setConnTimeout(5); + httpProfile.setReadTimeout(10); + httpProfile.setWriteTimeout(10); + ClientProfile profile = new ClientProfile(); + profile.setHttpProfile(httpProfile); + SmsClient client = new SmsClient(credential, conf.get("sms.tencent.region", "ap-guangzhou"), profile); + + SendSmsRequest request = new SendSmsRequest(); + request.setSmsSdkAppid(requiredConfig("sms.tencent.appid")); + request.setSign(requiredConfig("sms.tencent.sign")); + request.setTemplateID(requiredConfig("sms.tencent.tpl.login_code")); + request.setPhoneNumberSet(new String[]{"+86" + mobile}); + request.setTemplateParamSet(new String[]{code}); + checkSendStatus(client.SendSms(request), "+86" + mobile); + } catch (TencentCloudSDKException e) { + // SDK 网络异常可能没有 errorCode,实际异常类型及原因保存在 message 中。 + // 各字段先脱敏再输出,不直接打印异常对象,避免原始消息中的敏感信息进入日志。 + long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000L; + log.warnf("腾讯云登录短信调用失败,code=%s,requestId=%s,elapsedMs=%s,reason=%s", + sanitizeDiagnostic(e.getErrorCode(), mobile, code), + sanitizeDiagnostic(e.getRequestId(), mobile, code), elapsedMillis, + sanitizeDiagnostic(e.getMessage(), mobile, code)); + throw new BaseException("短信发送失败,请稍后重试或联系管理员"); + } + } + + /** + * 保留 SDK 异常诊断信息,移除可能夹带的凭证、手机号与短信码,并限制为单行日志。 + * + * @param value SDK 返回的错误码、请求编号或异常消息,空值显示为 unavailable + * @param mobile 本次发送的大陆手机号,禁止在诊断字段中原样输出 + * @param code 本次发送的六位短信验证码,禁止在诊断字段中原样输出 + * @return 已脱敏的单行文本,最长 1000 个字符,超长内容附加省略标记 + */ + private String sanitizeDiagnostic(String value, String mobile, String code) { + if (Strings.isBlank(value)) { + return "unavailable"; + } + String sanitized = value; + String[] sensitiveValues = {Strings.trim(conf.get("sms.tencent.secret-id", "")), + Strings.trim(conf.get("sms.tencent.secret-key", "")), mobile, code}; + for (String sensitive : sensitiveValues) { + if (!Strings.isBlank(sensitive)) { + sanitized = sanitized.replace(sensitive, "[REDACTED]"); + } + } + // 同时遮盖异常文本中其他大陆手机号、签名及授权字段;先脱敏,再截断。 + sanitized = sanitized.replaceAll("(? 1000 ? sanitized.substring(0, 1000) + "..." : sanitized; + } + + /** + * 核对单号码业务发送状态,HTTP 调用成功不代表短信发送成功。 + * + * @param response 腾讯云发送响应,必须恰有一个与请求号码相同的成功状态 + * @param phoneNumber 请求中的 +86 国际格式号码 + */ + private void checkSendStatus(SendSmsResponse response, String phoneNumber) { + SendStatus[] statuses = response == null ? null : response.getSendStatusSet(); + if (statuses == null || statuses.length != 1 || statuses[0] == null + || !"Ok".equals(statuses[0].getCode()) + || !phoneNumber.equals(statuses[0].getPhoneNumber())) { + String statusCode = statuses != null && statuses.length > 0 && statuses[0] != null + ? statuses[0].getCode() : "EmptyStatus"; + log.warnf("腾讯云登录短信未发送成功,code=%s,requestId=%s", statusCode, + response == null ? "" : response.getRequestId()); + throw new BaseException("短信发送失败,请稍后重试或联系管理员"); + } + } + + /** + * 获取必填腾讯云配置,错误提示仅包含配置名称。 + * + * @param key sms.tencent 下的完整配置键 + * @return 去除首尾空格的非空配置值 + */ + private String requiredConfig(String key) { + String value = Strings.trim(conf.get(key, "")); + if (Strings.isBlank(value)) { + throw new BaseException("短信服务配置不完整,请联系管理员检查 " + key); + } + return value; + } +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java index fe93d84a..6829946c 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java @@ -313,4 +313,15 @@ public class UnionReimburseApplyController { public Result getBudgetBalance(String reimburseFundSource, String clubId) { return unionReimburseService.getBudgetBalance(reimburseFundSource, clubId, null); } + + /** + * 查询可申请社团经费报销的社团。 + * + * @return 全部未解散社团列表 + */ + @At + @SaCheckPermission(value = {"unionReimburse.apply", "h5.unionReimburse.apply"}, mode = SaMode.OR) + public Result listReimburseClubs() { + return Result.success(unionReimburseService.listReimburseClubs()); + } } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java index 63996dc9..01607f97 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java @@ -121,6 +121,16 @@ public class UnionReimburseCollectController { applyListSort(cnd, pageForm); sql.setCondition(cnd); Pagination pagination = unionReimburseService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + // 复用打印流水号,按当日全部申请排序,避免分页或筛选改变编号。 + for (NutMap row : pagination.getList()) { + UnionReimburse form = new UnionReimburse(); + form.setId(row.getString("id")); + // SQL映射可能返回日期字符串,兼容日期对象后再参与流水号计算。 + Object createTime = row.get("createTime"); + form.setCreateTime(createTime instanceof java.util.Date ? (java.util.Date) createTime + : (createTime == null ? null : DateUtil.parse(createTime.toString()))); + row.put("serialNo", unionReimburseExportService.buildPrintSerialNo(form)); + } return Result.success(pagination); } @@ -166,6 +176,7 @@ public class UnionReimburseCollectController { WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_2' THEN '文体活动' WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_3' THEN '日常活动' WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_4' THEN '专项活动' + WHEN rei.reimburseProject = 'UNION_REIMBURSE_PROJECT_5' THEN '社团活动' ELSE rei.reimburseProject END as reimbursementItemName, u.mobile @@ -249,6 +260,8 @@ public class UnionReimburseCollectController { .orElse(null); if (projectDict != null) { item.setReimburseProject(projectDict.getName()); + } else if ("UNION_REIMBURSE_PROJECT_5".equals(item.getReimburseProject())) { + item.setReimburseProject("社团活动"); } } // 转换报销经费来源 diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseReviewController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseReviewController.java index 9a223ed2..f2c7d083 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseReviewController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseReviewController.java @@ -2,6 +2,7 @@ package com.budwk.app.zhgh.dayofficework.unionReimburse.controller; import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaMode; +import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.StrUtil; import com.budwk.app.base.annotation.SLog; import com.budwk.app.base.page.Pagination; @@ -10,6 +11,7 @@ import com.budwk.app.base.result.Result; import com.budwk.app.base.utils.PageUtil; import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse; import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseService; +import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseExportService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.nutz.aop.interceptor.ioc.TransAop; @@ -36,6 +38,9 @@ public class UnionReimburseReviewController { @Inject private UnionReimburseService unionReimburseService; + @Inject + private UnionReimburseExportService unionReimburseExportService; + @At("/index") @Ok("beetl:/platform/zhgh/dayofficework/unionReimburse/review/index.html") @SaCheckPermission("unionReimburse.review") @@ -88,6 +93,16 @@ public class UnionReimburseReviewController { cnd.desc("info.createdAt"); sql.setCondition(cnd); Pagination pagination = unionReimburseService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + // 复用打印流水号,按当日全部申请排序,避免分页或筛选改变编号。 + for (NutMap row : pagination.getList()) { + UnionReimburse form = new UnionReimburse(); + form.setId(row.getString("id")); + // SQL映射可能返回日期字符串,兼容日期对象后再参与流水号计算。 + Object createTime = row.get("createTime"); + form.setCreateTime(createTime instanceof java.util.Date ? (java.util.Date) createTime + : (createTime == null ? null : DateUtil.parse(createTime.toString()))); + row.put("serialNo", unionReimburseExportService.buildPrintSerialNo(form)); + } return Result.success(pagination); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java index 8fea2773..7fc7e99a 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java @@ -24,4 +24,11 @@ public interface UnionReimburseExportService { * @return 活动及慰问模板通用的平铺字段及 payments、invoices 循环行 */ Map buildExportData(UnionReimburse form); + + /** + * 列表与打印共用按申请日期及当日申请时间排序的流水号,历史申请参与相同排序。 + * @param form 报销申请,必须包含申请时间和单据ID + * @return 年月日加至少三位当日序号,例如20261009001;缺少必要字段或申请不存在时抛出业务异常 + */ + String buildPrintSerialNo(UnionReimburse form); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java index 138f5a01..2d272ba0 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java @@ -58,10 +58,17 @@ public interface UnionReimburseService extends BaseService { Result recognizeInvoice(String fileId, Boolean verifySwitch); /** - * 查询报销可用经费余额,同时校验是否已分配以及协会归属权限。 + * 查询报销可用经费余额,同时校验是否已分配、经费来源权限及社团有效性。 */ Result getBudgetBalance(String reimburseFundSource, String clubId, String unionId); + /** + * 查询未解散社团,供社团经费报销选择。 + * + * @return 全部未解散社团的ID、名称和编码列表 + */ + List listReimburseClubs(); + /** * 审核报销,审核通过时扣减对应预算并新增预算使用详情。 */ diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java index 65440ee1..46bedabc 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java @@ -7,6 +7,8 @@ import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.web.commons.auth.utils.AuthUtil; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.utils.MoneyUtil; import com.budwk.app.base.utils.OfficePlusUtil; @@ -20,23 +22,10 @@ import com.budwk.app.zhgh.staffbenefit.condolence.model.CondolenceType; import com.deepoove.poi.XWPFTemplate; import com.deepoove.poi.config.Configure; import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy; -import com.deepoove.poi.data.PictureType; -import com.deepoove.poi.data.Pictures; -import com.deepoove.poi.template.ElementTemplate; -import com.google.zxing.*; -import com.google.zxing.client.j2se.BufferedImageLuminanceSource; -import com.google.zxing.client.j2se.MatrixToImageWriter; -import com.google.zxing.common.HybridBinarizer; -import com.google.zxing.qrcode.QRCodeWriter; -import com.google.zxing.qrcode.QRCodeReader; -import org.apache.poi.xwpf.usermodel.*; -import org.apache.poi.util.Units; -import javax.imageio.ImageIO; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import lombok.extern.slf4j.Slf4j; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.util.NutMap; import java.io.InputStream; import java.io.OutputStream; import java.math.BigDecimal; @@ -139,7 +128,7 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ return CONDOLENCE_TEMPLATE; } - /** 两个入口按慰问类型选模板并共用字段和循环行配置;汇总打印额外补充编号二维码。 */ + /** 两个入口按慰问类型选模板并共用流水号字段和循环行配置,不再生成二维码。 */ private byte[] renderDocument(UnionReimburse form, boolean asPdf, boolean collectPrint) { String templateCode = resolveTemplateCode(form, collectPrint); Path docx = null; @@ -157,9 +146,6 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ XWPFTemplate template = XWPFTemplate.compile(input, config); OutputStream output = Files.newOutputStream(docx)) { Map data = buildExportData(form); - if (collectPrint) { - prepareCollectQrCode(template, data, form.getDocumentNo()); - } template.render(data).write(output); } if (!asPdf) { @@ -193,71 +179,41 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ } /** - * 汇总打印沿用老系统的单据编号二维码,仅修改本次生成的文档,不覆盖模板。 - * @param template 当前已编译模板,优先使用 qrcode 图片标记,其次替换标题区的固定二维码 - * @param data 本次渲染数据,qrcode 字段为 80×80 的 PNG 图片对象 - * @param documentNo 原始单据编号,缺失时拒绝打印,避免用业务 ID 冒充编号 + * 按申请日期和当日申请时间顺序生成打印流水号,六类模板使用同一编号口径。 + * 历史单据也按申请时间参与排序,同一秒内用创建时间及主键确定顺序; + * 不按打印顺序编号,也不按当前用户或报销状态过滤申请。 + * + * @param form 报销申请,使用其单据ID读取数据库中的申请日期 + * @return 年月日加至少三位当日序号,例如 20261009001 */ - private void prepareCollectQrCode(XWPFTemplate template, Map data, - String documentNo) throws Exception { - if (StrUtil.isBlank(documentNo)) { - throw new BaseException("报销单编号为空,无法生成打印二维码,请先核对单据编号"); + @Override + public String buildPrintSerialNo(UnionReimburse form) { + if (form == null || StrUtil.isBlank(form.getId())) { + throw new BaseException("报销单据ID为空,无法生成打印流水号"); } - var matrix = new QRCodeWriter().encode(documentNo, BarcodeFormat.QR_CODE, 80, 80); - var image = MatrixToImageWriter.toBufferedImage(matrix); - data.put("qrcode", Pictures.ofBufferedImage(image, PictureType.PNG).size(80, 80).create()); - boolean hasTag = template.getElementTemplates().stream() - .anyMatch(item -> item instanceof ElementTemplate - && "qrcode".equals(((ElementTemplate) item).getTagName())); - if (hasTag) { - return; + // 日期范围和编号前缀均取数据库原值,避免Java日期转换及参数绑定影响当天范围。 + // 先对目标申请当天的全部单据排序,再筛选目标ID,保持审核、汇总和打印的编号一致。 + Sql sql = Sqls.create(""" + SELECT DATE_FORMAT(numbered.createTime, '%Y%m%d') AS serialDate, numbered.seq + FROM ( + SELECT info.id, info.createTime, + ROW_NUMBER() OVER (ORDER BY info.createTime, info.createdAt, info.id) AS seq + FROM union_reimburse info + INNER JOIN union_reimburse target ON target.id = @id + WHERE info.createTime >= DATE(target.createTime) + AND info.createTime < DATE_ADD(DATE(target.createTime), INTERVAL 1 DAY) + ) numbered + WHERE numbered.id = @id + """); + sql.setParam("id", form.getId()); + sql.setCallback(Sqls.callback.map()); + dao.execute(sql); + NutMap result = sql.getObject(NutMap.class); + if (result == null || !(result.get("seq") instanceof Number sequence) + || sequence.longValue() < 1 || StrUtil.isBlank(result.getString("serialDate"))) { + throw new BaseException("未找到报销申请或申请时间为空,无法生成打印流水号"); } - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - ImageIO.write(image, "png", bytes); - XWPFDocument document = template.getXWPFDocument(); - // 部分现用模板内嵌了固定二维码。仅识别第一张业务表格之前的二维码,保留徽标等普通图片。 - boolean replaced = false; - for (IBodyElement element : document.getBodyElements()) { - if (element instanceof XWPFTable) break; - if (!(element instanceof XWPFParagraph)) continue; - for (XWPFRun run : ((XWPFParagraph) element).getRuns()) { - for (XWPFPicture picture : run.getEmbeddedPictures()) { - if (picture.getPictureData() == null) continue; - var existing = ImageIO.read(new ByteArrayInputStream(picture.getPictureData().getData())); - if (existing == null) continue; - try { - new QRCodeReader().decode(new BinaryBitmap(new HybridBinarizer( - new BufferedImageLuminanceSource(existing)))); - } catch (NotFoundException | ChecksumException | FormatException e) { - continue; - } - String relationship = document.addPictureData(bytes.toByteArray(), Document.PICTURE_TYPE_PNG); - picture.getCTPicture().getBlipFill().getBlip().setEmbed(relationship); - replaced = true; - } - } - } - if (replaced) return; - // 无二维码标记或固定二维码的模板,在正文开头添加右对齐图片,不依赖模板重新上传。 - XWPFParagraph paragraph; - if (document.getBodyElements().isEmpty()) { - paragraph = document.createParagraph(); - } else { - IBodyElement first = document.getBodyElements().get(0); - var cursor = first instanceof XWPFParagraph - ? ((XWPFParagraph) first).getCTP().newCursor() - : ((XWPFTable) first).getCTTbl().newCursor(); - try { - paragraph = document.insertNewParagraph(cursor); - } finally { - cursor.dispose(); - } - } - paragraph.setAlignment(ParagraphAlignment.RIGHT); - paragraph.setSpacingBefore(0); - paragraph.setSpacingAfter(0); - paragraph.createRun().addPicture(new ByteArrayInputStream(bytes.toByteArray()), - Document.PICTURE_TYPE_PNG, "qrcode.png", Units.pixelToEMU(80), Units.pixelToEMU(80)); + return result.getString("serialDate") + String.format(Locale.ROOT, "%03d", sequence.longValue()); } /** 平铺字段供两类模板复用,支付总额与发票总额分别计算,不使用第一条支付代替全部明细。 */ @@ -266,8 +222,11 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ Map data = new LinkedHashMap<>(); data.put("date", date(form.getCreateTime())); data.put("no", text(form.getDocumentNo())); + data.put("serialNo", buildPrintSerialNo(form)); String unit = text(form.getUnitName()); - data.put("union", text(form.getUnionName()) + (unit.isEmpty() ? "" : "(" + unit + ")")); + data.put("union", "UNION_REIMBURSE_FUND_SOURCE_3".equals(form.getReimburseFundSource()) + ? text(form.getClubName()) + : text(form.getUnionName()) + (unit.isEmpty() ? "" : "(" + unit + ")")); data.put("handler", text(form.getUserName())); data.put("login", text(form.getLoginName())); data.put("phone", text(form.getMobile())); @@ -276,6 +235,9 @@ public class UnionReimburseExportServiceImpl implements UnionReimburseExportServ + ("UNION_REIMBURSE_PROJECT_1".equals(project) ? "√" : "□") + "慰问 " + ("UNION_REIMBURSE_PROJECT_3".equals(project) ? "√" : "□") + "日常活动 " + ("UNION_REIMBURSE_PROJECT_4".equals(project) ? "√" : "□") + "专项活动"); + if ("UNION_REIMBURSE_PROJECT_5".equals(project)) { + data.put("project", "√社团活动"); + } data.put("activity", text(form.getActivityName())); data.put("reason", text(form.getPaymentNotes())); data.put("notes", text(form.getNotes())); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java index 41632e97..2137e618 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java @@ -11,7 +11,6 @@ import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.sys.models.Sys_file; import com.budwk.app.sys.views.View_user; import com.budwk.app.sys.services.SysFileService; -import com.budwk.app.zhgh.club.model.ClubUser; import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.club.model.OutlayManageClub; import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.model.OutlayUseDetail; import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.school.model.OutlayManageSchool; @@ -148,6 +147,10 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (amountResult != null) { return amountResult; } + Result activityAmountResult = validateActivityApplyMoney(unionReimburse); + if (activityAmountResult != null) { + return activityAmountResult; + } UnionReimburse oldRecord = null; if (StrUtil.isNotBlank(unionReimburse.getId())) { oldRecord = this.fetch(unionReimburse.getId()); @@ -159,6 +162,18 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i } } + Result sourceResult = validateApplicantFundSource(unionReimburse); + if (sourceResult != null) { + return sourceResult; + } + if (oldRecord != null + && ("UNION_REIMBURSE_FUND_SOURCE_3".equals(oldRecord.getReimburseFundSource()) + || "UNION_REIMBURSE_FUND_SOURCE_3".equals(unionReimburse.getReimburseFundSource())) + && !Integer.valueOf(1).equals(oldRecord.getStateId()) + && !Integer.valueOf(5).equals(oldRecord.getStateId())) { + return Result.error("社团报销仅允许修改草稿或退回的申请"); + } + // 人员身份和敏感资料由服务端补齐,避免前端删减字段导致丢失或被篡改。 if (oldRecord == null) { View_user handler = dao().fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId())); @@ -491,6 +506,18 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i */ @Override public Result getBudgetBalance(String reimburseFundSource, String clubId, String unionId) { + if (!canApplyFundSource(reimburseFundSource)) { + return Result.error("当前角色无权使用该经费来源"); + } + if ("UNION_REIMBURSE_FUND_SOURCE_3".equals(reimburseFundSource) + && listReimburseClubs().stream().noneMatch(club -> Objects.equals(clubId, club.getString("id")))) { + return Result.error("请选择有效的社团"); + } + return queryBudgetBalance(reimburseFundSource, clubId, unionId); + } + + // 审核人按申请单的经费归属校验预算,不要求审核人同时是该社团负责人。 + private Result queryBudgetBalance(String reimburseFundSource, String clubId, String unionId) { if (StrUtil.isBlank(reimburseFundSource)) { return Result.error("请选择经费来源"); } @@ -515,16 +542,13 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (StrUtil.isBlank(clubId)) { return Result.error("请选择协会"); } - List myClubIds = getCurrentUserClubIds(); - if (!isAdmin() && !myClubIds.contains(clubId)) { - return Result.error("当前用户无权使用该协会经费"); - } + OutlayManageClub outlayManageClub = dao().fetch(OutlayManageClub.class, Cnd.where("clubId", "=", clubId).and("year", "=", DateUtil.thisYear())); if (outlayManageClub == null) { return Result.error("该协会本年度未生成预算记录,请先在协会预算分配中补分配"); } - return Result.success(formatMoney(outlayManageClub.getTotalQuota().subtract(outlayManageClub.getUsedQuota()))); + return Result.success(formatMoney(safeMoney(outlayManageClub.getTotalQuota()).subtract(safeMoney(outlayManageClub.getUsedQuota())))); } return Result.error("不支持的经费来源"); } @@ -533,6 +557,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i * 审核通过时统一校验余额、扣减预算并写入预算使用详情,避免控制器分散业务逻辑。 */ @Override + @Aop(TransAop.READ_COMMITTED) public Result reviewApply(String reimburseId, String reviewOpinion, int submitType) { if (!List.of(3, 4, 5).contains(submitType)) { return Result.error("无效的审核操作类型"); @@ -541,6 +566,25 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (dbRecord == null) { return Result.error("记录不存在"); } + if ("UNION_REIMBURSE_FUND_SOURCE_3".equals(dbRecord.getReimburseFundSource())) { + // 同一申请审核串行处理,避免重复点击导致社团已使用额度累计两次。 + Sql lockSql = Sqls.create("SELECT * FROM union_reimburse WHERE id=@id FOR UPDATE"); + lockSql.setParam("id", reimburseId); + lockSql.setEntity(dao().getEntity(UnionReimburse.class)); + lockSql.setCallback(Sqls.callback.entities()); + dao().execute(lockSql); + List records = lockSql.getList(UnionReimburse.class); + if (records.isEmpty()) { + return Result.error("记录不存在"); + } + dbRecord = records.get(0); + if (submitType == 3 && Integer.valueOf(3).equals(dbRecord.getStateId())) { + return Result.success(); + } + if (!Integer.valueOf(2).equals(dbRecord.getStateId())) { + return Result.error("仅可审核待审核确认的社团报销"); + } + } if (submitType == 3) { // 已通过的单据再次触发通过时直接返回,避免重复扣减预算。 if (Integer.valueOf(3).equals(dbRecord.getStateId())) { @@ -668,8 +712,43 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i unionReimburse.setInvoiceDetails(invoiceDetails); } + private boolean isManualReimburseAmount(UnionReimburse form) { + return "UNION_REIMBURSE_PROJECT_2".equals(form.getReimburseProject()) + || "UNION_REIMBURSE_PROJECT_3".equals(form.getReimburseProject()) + || "UNION_REIMBURSE_PROJECT_4".equals(form.getReimburseProject()); + } + /** - * 有发票时汇总票面金额和张数;无发票时按已校验的支付明细汇总金额,张数为零。 + * 文体、日常、专项活动保留手填报销金额,有发票时限制票面总额,无发票不设此上限。 + * @param form 已通过发票和支付信息校验的申请 + * @return 金额必填、精度或上限校验不通过时返回错误,通过返回null + */ + private Result validateActivityApplyMoney(UnionReimburse form) { + if (!isManualReimburseAmount(form)) { + return null; + } + Double money = form.getMoney(); + if (money == null) { + return Result.error("请填写报销金额"); + } + if (!Double.isFinite(money) || money <= 0 + || BigDecimal.valueOf(money).stripTrailingZeros().scale() > 2) { + return Result.error("报销金额必须为正数,且最多两位小数"); + } + if (Lang.isNotEmpty(form.getInvoiceDetails())) { + BigDecimal invoiceTotal = BigDecimal.ZERO; + for (UnionReimburseInvoiceDetail detail : form.getInvoiceDetails()) { + invoiceTotal = invoiceTotal.add(BigDecimal.valueOf(detail.getInvoiceAmount()).setScale(2, RoundingMode.HALF_UP)); + } + if (BigDecimal.valueOf(money).compareTo(invoiceTotal) > 0) { + return Result.error("报销金额不能超过发票总额(" + invoiceTotal.toPlainString() + "元)"); + } + } + return null; + } + + /** + * 汇总票面金额和张数;三类活动保留手填金额,其余项目无发票时按支付明细汇总。 */ private void fillInvoiceSummary(UnionReimburse unionReimburse) { double totalMoney = 0D; @@ -692,7 +771,10 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i } totalMoney = paymentTotal.setScale(2, RoundingMode.HALF_UP).doubleValue(); } - unionReimburse.setMoney(BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue()); + // 三类活动使用已校验的手填申请金额,不能被发票或支付合计覆盖。 + if (!isManualReimburseAmount(unionReimburse)) { + unionReimburse.setMoney(BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue()); + } unionReimburse.setInvoiceNumber(String.valueOf(invoiceFileCount)); } @@ -826,7 +908,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (unionReimburse.getActivityTime() == null) { return Result.error("请选择活动时间"); } - if (Lang.isEmpty(unionReimburse.getFiles())) { + // 文体、日常和社团活动附件选填,其他活动保留附件必填校验。 + if (!"UNION_REIMBURSE_PROJECT_2".equals(unionReimburse.getReimburseProject()) + && !"UNION_REIMBURSE_PROJECT_3".equals(unionReimburse.getReimburseProject()) + && !"UNION_REIMBURSE_PROJECT_5".equals(unionReimburse.getReimburseProject()) + && Lang.isEmpty(unionReimburse.getFiles())) { return Result.error("请上传附件"); } return validateInvoiceDetails(unionReimburse); @@ -929,7 +1015,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i * 提交和审核前统一校验预算是否已分配、余额是否充足。 */ private Result validateFundSourceBeforeSubmit(UnionReimburse unionReimburse) { - Result balanceResult = getBudgetBalance(unionReimburse.getReimburseFundSource(), unionReimburse.getClubId(), unionReimburse.getUnionId()); + Result balanceResult = queryBudgetBalance(unionReimburse.getReimburseFundSource(), unionReimburse.getClubId(), unionReimburse.getUnionId()); if (balanceResult == null || balanceResult.getCode() != 0) { return balanceResult == null ? Result.error("经费余额校验失败") : balanceResult; } @@ -985,11 +1071,19 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (outlayManageClub == null) { return Result.error("该协会本年度未生成预算记录,请先在协会预算分配中补分配"); } - if (outlayManageClub.getTotalQuota().subtract(outlayManageClub.getUsedQuota()).compareTo(applyMoney) < 0) { - return Result.error("协会经费余额不足"); + // 本年度分配总额度保持不变,审批金额原子累加至已使用额度,余额由两者相减得到。 + Sql deductSql = Sqls.create(""" + UPDATE outlay_manage_club + SET usedQuota = COALESCE(usedQuota, 0) + @amount + WHERE id = @id + AND COALESCE(totalQuota, 0) - COALESCE(usedQuota, 0) >= @amount + """); + deductSql.setParam("id", outlayManageClub.getId()); + deductSql.setParam("amount", applyMoney); + dao().execute(deductSql); + if (deductSql.getUpdateCount() != 1) { + return Result.error("社团经费余额不足"); } - outlayManageClub.setUsedQuota(outlayManageClub.getUsedQuota().add(applyMoney)); - dao().updateIgnoreNull(outlayManageClub); insertUseDetail(unionReimburse, outlayManageClub.getId(), applyMoney); return Result.success(); } @@ -1201,6 +1295,9 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if ("UNION_REIMBURSE_PROJECT_4".equals(reimburseProject)) { return "专项活动申请"; } + if ("UNION_REIMBURSE_PROJECT_5".equals(reimburseProject)) { + return "社团活动申请"; + } return "报销申请"; } @@ -1208,16 +1305,57 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i return money == null ? 0D : money.setScale(2, RoundingMode.HALF_UP).doubleValue(); } - private List getCurrentUserClubIds() { - if (isAdmin()) { - return new ArrayList<>(); + /** + * 查询未解散社团,社团选择和余额查询不按负责人归属过滤。 + * + * @return 全部未解散社团的ID、名称和编码 + */ + @Override + public List listReimburseClubs() { + Sql sql = Sqls.create(""" + SELECT c.id, c.clubName, c.clubCode + FROM sys_club c + WHERE c.dismiss = 0 + ORDER BY c.clubCode, c.id + """); + return listMap(sql); + } + + // 社团经费按菜单权限授权,其他经费保留原申请角色判断。 + private boolean canApplyFundSource(String source) { + if (StrUtil.isBlank(source)) { + return false; } - List clubUsers = dao().query(ClubUser.class, Cnd.where("userId", "=", SecurityUtil.getUserId())); - return clubUsers.stream() - .map(ClubUser::getClubId) - .filter(StrUtil::isNotBlank) - .distinct() - .collect(Collectors.toList()); + boolean branchRole = AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_CHAIRMAN.name(), RoleConstant.BRANCH_UNION_ADMIN.name()); + boolean clubRole = AuthUtil.hasRoleOr(RoleConstant.CLUB_MANAGER.name(), RoleConstant.CLUB_PRESIDENT.name()); + return switch (source) { + case "UNION_REIMBURSE_FUND_SOURCE_1" -> isAdmin() + || (!branchRole && !clubRole && AuthUtil.hasRoleOr(RoleConstant.SCHOOL_OUTLAY_ADMIN.name())); + case "UNION_REIMBURSE_FUND_SOURCE_2" -> isAdmin() || branchRole; + case "UNION_REIMBURSE_FUND_SOURCE_3" -> AuthUtil.hasPermission("unionReimburse.club"); + default -> false; + }; + } + + private Result validateApplicantFundSource(UnionReimburse form) { + if (!canApplyFundSource(form.getReimburseFundSource())) { + return Result.error("当前角色无权使用该经费来源"); + } + if ("UNION_REIMBURSE_FUND_SOURCE_3".equals(form.getReimburseFundSource())) { + if (!"UNION_REIMBURSE_PROJECT_5".equals(form.getReimburseProject())) { + return Result.error("社团经费请选择社团活动"); + } + NutMap club = listReimburseClubs().stream() + .filter(item -> Objects.equals(form.getClubId(), item.getString("id"))) + .findFirst().orElse(null); + if (club == null) { + return Result.error("请选择有效的社团"); + } + form.setClubName(club.getString("clubName")); + } else if ("UNION_REIMBURSE_PROJECT_5".equals(form.getReimburseProject())) { + return Result.error("社团活动只能使用社团经费"); + } + return null; } private Result validateInvoiceDuplicate(UnionReimburse unionReimburse) { diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/member/controller/apply/MemberApplyController.java b/src/main/java/com/budwk/app/zhgh/staffmanage/member/controller/apply/MemberApplyController.java index 90899d98..8ee03e57 100644 --- a/src/main/java/com/budwk/app/zhgh/staffmanage/member/controller/apply/MemberApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/member/controller/apply/MemberApplyController.java @@ -12,6 +12,8 @@ import com.budwk.app.flow.engine.FlowEngine; import com.budwk.app.flow.entity.ProcessInstance; import com.budwk.app.flow.entity.ProcessTask; import com.budwk.app.flow.enums.ProcessSubmitTypeEnum; +import com.budwk.app.flow.enums.ProcessInstanceStateEnum; +import com.budwk.app.flow.enums.ProcessTaskStateEnum; import com.budwk.app.flow.service.FlowCommonService; import com.budwk.app.sys.models.Sys_union_group; import com.budwk.app.web.commons.auth.utils.AuthUtil; @@ -31,6 +33,7 @@ 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; import org.nutz.mvc.annotation.Param; @@ -87,9 +90,12 @@ public class MemberApplyController { @SaCheckPermission("member.apply.submit") @SLog(tag = "会员入会申请", msg = "提交申请,申请人: ${args[0].username}") public Result submit(@Param("data") MemberApplyRecord memberApplyRecord){ - if (StrUtil.isBlank(memberApplyRecord.getId())) { - memberCommonService.validateApplyOrChangeIsDoing(null, "apply"); + lockApplicant(); + String blockMessage = getApplyBlockMessage(currentUser(), memberApplyRecord.getId(), null); + if (StrUtil.isNotBlank(blockMessage)) { + return Result.error(blockMessage); } + memberApplyRecord.setUserId(SecurityUtil.getUserId()); // 开启流程实例 Dict args = Dict.create(); // 设置流程变量 @@ -126,9 +132,15 @@ public class MemberApplyController { @SaCheckPermission("member.apply.submit") @SLog(tag = "会员入会申请", msg = "重新提交申请,申请人: ${args[0].username}") public Result submitAgain(@Param("data") MemberApplyRecord memberApplyRecord, @Param("taskId") Long taskId) { - if (StrUtil.isBlank(memberApplyRecord.getId())) { - memberCommonService.validateApplyOrChangeIsDoing(null, "apply"); + lockApplicant(); + String blockMessage = getApplyBlockMessage(currentUser(), memberApplyRecord.getId(), taskId); + if (StrUtil.isNotBlank(blockMessage)) { + return Result.error(blockMessage); } + if (taskId == null) { + return Result.error("申请任务不存在或已处理,请勿重复提交"); + } + memberApplyRecord.setUserId(SecurityUtil.getUserId()); Dict dict = Dict.create(); // 设置流程变量 if ("HMC".equals(Globals.SchoolCode)) { @@ -190,14 +202,86 @@ public class MemberApplyController { /** - * 获取当前登录用户信息,是用来判断当前用户是否是会员 - * @return + * 获取当前登录用户信息及入会申请限制,供页面进入时提示。 + * + * @param id 当前编辑的申请ID,新申请可为空 + * @param taskId 重新提交的申请任务ID,新申请可为空 + * @return 用户资料及禁止提交的原因;合法退回重提不视为重复申请 */ @At @ApiOperation("获取当前登录用户信息") @SaCheckPermission("member.apply.submit") - public Result getSelfUserInfo() { - return Result.success(dao.fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId()))); + public Result getSelfUserInfo(String id, Long taskId) { + View_user user = currentUser(); + if (user == null) { + return Result.error("无法获取当前用户信息"); + } + NutMap data = Lang.obj2map(user, NutMap.class); + data.setv("applicationBlockMessage", getApplyBlockMessage(user, id, taskId)); + return Result.success(data); + } + + private View_user currentUser() { + return dao.fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId())); + } + + // 在提交事务中锁定申请人,避免并发请求同时通过检查并开启多个流程。 + private void lockApplicant() { + Sql sql = Sqls.create("SELECT id FROM sys_user WHERE id=@userId FOR UPDATE"); + sql.setParam("userId", SecurityUtil.getUserId()); + sql.setCallback(Sqls.callback.records()); + dao.execute(sql); + } + + // 页面提示与提交接口共用实时校验;只放行本人退回或撤回后仍待处理的申请节点。 + private String getApplyBlockMessage(View_user user, String id, Long taskId) { + if (user == null) { + return "无法获取当前用户信息"; + } + if (user.getMember() != null && user.getMember() != 0) { + return "您已是工会会员,无需重复提交入会申请"; + } + if (StrUtil.isNotBlank(id)) { + MemberApplyRecord record = dao.fetch(MemberApplyRecord.class, id); + if (record == null || !SecurityUtil.getUserId().equals(record.getUserId())) { + return "申请记录不存在或不属于当前用户"; + } + } + Long excludedInstanceId = -1L; + if (taskId != null) { + ProcessTask task = dao.fetch(ProcessTask.class, taskId); + ProcessInstance instance = task == null ? null : dao.fetch(ProcessInstance.class, task.getProcessInstanceId()); + if (StrUtil.isBlank(id) || task == null || instance == null + || !"startTask".equals(task.getTaskName()) + || !ProcessTaskStateEnum.DOING.getCode().equals(task.getTaskState()) + || !ProcessInstanceStateEnum.DOING.getCode().equals(instance.getState()) + || !id.equals(instance.getBusinessNo())) { + return "申请任务不存在或已处理,请勿重复提交"; + } + excludedInstanceId = instance.getId(); + } + // 检查全部申请,避免较新的草稿遮蔽仍在审批中的旧申请。 + Sql sql = Sqls.create(""" + SELECT COUNT(*) + FROM member_apply_record r + INNER JOIN wf_process_instance p ON p.businessNo = r.id + WHERE r.userId = @userId AND p.state IN (@doing, @pending) + AND p.id <> @excludedInstanceId + """); + sql.setParam("userId", SecurityUtil.getUserId()); + sql.setParam("doing", ProcessInstanceStateEnum.DOING.getCode()); + sql.setParam("pending", ProcessInstanceStateEnum.PENDING.getCode()); + sql.setParam("excludedInstanceId", excludedInstanceId); + sql.setCallback(Sqls.callback.integer()); + dao.execute(sql); + if (sql.getInt() > 0) { + return "您已有入会申请正在处理中,请勿重复提交"; + } + if (taskId == null && StrUtil.isNotBlank(id) + && dao.count(ProcessInstance.class, Cnd.where("businessNo", "=", id)) > 0) { + return "该申请已提交,请勿重复提交"; + } + return null; } } diff --git a/src/main/resources/db/sys_club_add_thumbnail.sql b/src/main/resources/db/sys_club_add_thumbnail.sql new file mode 100644 index 00000000..a62f9ed9 --- /dev/null +++ b/src/main/resources/db/sys_club_add_thumbnail.sql @@ -0,0 +1,19 @@ +-- 社团缩略图保存系统文件下载地址;保留可空以兼容未上传图片的历史社团。 +-- 字段存在时跳过,避免重复执行迁移时报错。 +SET @club_thumbnail_column_count = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_club' + AND COLUMN_NAME = 'thumbnail' +); + +SET @club_thumbnail_ddl = IF( + @club_thumbnail_column_count = 0, + 'ALTER TABLE sys_club ADD COLUMN thumbnail VARCHAR(1000) NULL COMMENT ''社团缩略图'' AFTER clubName', + 'SELECT 1' +); + +PREPARE club_thumbnail_stmt FROM @club_thumbnail_ddl; +EXECUTE club_thumbnail_stmt; +DEALLOCATE PREPARE club_thumbnail_stmt; diff --git a/src/main/resources/db/union_reimburse_add_club_activity_dict.sql b/src/main/resources/db/union_reimburse_add_club_activity_dict.sql new file mode 100644 index 00000000..f488944d --- /dev/null +++ b/src/main/resources/db/union_reimburse_add_club_activity_dict.sql @@ -0,0 +1,38 @@ +-- 社团活动属于报销项目的正式字典配置,两端沿用现有字典接口读取。 +-- 仅补充缺失记录;已有记录(包括已禁用记录)不覆盖,不在查询接口中添加兜底项。 +INSERT INTO sys_dict ( + id, parentId, path, name, remark, code, disabled, location, hasChildren, + createdBy, createdAt, updatedBy, updatedAt, delFlag +) +SELECT + REPLACE(UUID(), '-', ''), + parent.id, + CONCAT(parent.path, LPAD(IFNULL(( + SELECT MAX(CAST(RIGHT(child.path, 4) AS UNSIGNED)) + FROM sys_dict child + WHERE child.path LIKE CONCAT(parent.path, '____') + ), 0) + 1, 4, '0')), + '社团活动', + '', + 'UNION_REIMBURSE_PROJECT_5', + 0, + IFNULL((SELECT MAX(child.location) FROM sys_dict child WHERE child.parentId = parent.id), 0) + 1, + 0, + '', UNIX_TIMESTAMP(NOW()) * 1000, + '', UNIX_TIMESTAMP(NOW()) * 1000, + 0 +FROM sys_dict parent +WHERE parent.code = 'UNION_REIMBURSE_PROJECT' + AND NOT EXISTS ( + SELECT 1 + FROM (SELECT code FROM sys_dict WHERE code = 'UNION_REIMBURSE_PROJECT_5') existing_item + ); + +UPDATE sys_dict parent +SET parent.hasChildren = 1 +WHERE parent.code = 'UNION_REIMBURSE_PROJECT' + AND EXISTS ( + SELECT 1 + FROM (SELECT parentId FROM sys_dict WHERE code = 'UNION_REIMBURSE_PROJECT_5') club_activity + WHERE club_activity.parentId = parent.id + ); diff --git a/src/main/resources/static/assets/mobile/img/club/club-hero-v5.png b/src/main/resources/static/assets/mobile/img/club/club-hero-v5.png new file mode 100644 index 00000000..4a4cd69f Binary files /dev/null and b/src/main/resources/static/assets/mobile/img/club/club-hero-v5.png differ diff --git a/src/main/resources/static/captcha/slider/0.jpg b/src/main/resources/static/captcha/slider/0.jpg new file mode 100644 index 00000000..78b3ef65 Binary files /dev/null and b/src/main/resources/static/captcha/slider/0.jpg differ diff --git a/src/main/resources/static/captcha/slider/1.jpg b/src/main/resources/static/captcha/slider/1.jpg new file mode 100644 index 00000000..5310567f Binary files /dev/null and b/src/main/resources/static/captcha/slider/1.jpg differ diff --git a/src/main/resources/static/captcha/slider/10.jpg b/src/main/resources/static/captcha/slider/10.jpg new file mode 100644 index 00000000..83858e02 Binary files /dev/null and b/src/main/resources/static/captcha/slider/10.jpg differ diff --git a/src/main/resources/static/captcha/slider/11.jpg b/src/main/resources/static/captcha/slider/11.jpg new file mode 100644 index 00000000..2fe415f5 Binary files /dev/null and b/src/main/resources/static/captcha/slider/11.jpg differ diff --git a/src/main/resources/static/captcha/slider/12.jpg b/src/main/resources/static/captcha/slider/12.jpg new file mode 100644 index 00000000..a706b3a5 Binary files /dev/null and b/src/main/resources/static/captcha/slider/12.jpg differ diff --git a/src/main/resources/static/captcha/slider/13.jpg b/src/main/resources/static/captcha/slider/13.jpg new file mode 100644 index 00000000..56340b7e Binary files /dev/null and b/src/main/resources/static/captcha/slider/13.jpg differ diff --git a/src/main/resources/static/captcha/slider/14.jpg b/src/main/resources/static/captcha/slider/14.jpg new file mode 100644 index 00000000..1bae6f69 Binary files /dev/null and b/src/main/resources/static/captcha/slider/14.jpg differ diff --git a/src/main/resources/static/captcha/slider/15.jpg b/src/main/resources/static/captcha/slider/15.jpg new file mode 100644 index 00000000..4cfb338f Binary files /dev/null and b/src/main/resources/static/captcha/slider/15.jpg differ diff --git a/src/main/resources/static/captcha/slider/16.jpg b/src/main/resources/static/captcha/slider/16.jpg new file mode 100644 index 00000000..7f1417e0 Binary files /dev/null and b/src/main/resources/static/captcha/slider/16.jpg differ diff --git a/src/main/resources/static/captcha/slider/17.jpg b/src/main/resources/static/captcha/slider/17.jpg new file mode 100644 index 00000000..3b35c22b Binary files /dev/null and b/src/main/resources/static/captcha/slider/17.jpg differ diff --git a/src/main/resources/static/captcha/slider/18.jpg b/src/main/resources/static/captcha/slider/18.jpg new file mode 100644 index 00000000..51f2912f Binary files /dev/null and b/src/main/resources/static/captcha/slider/18.jpg differ diff --git a/src/main/resources/static/captcha/slider/19.jpg b/src/main/resources/static/captcha/slider/19.jpg new file mode 100644 index 00000000..b6311634 Binary files /dev/null and b/src/main/resources/static/captcha/slider/19.jpg differ diff --git a/src/main/resources/static/captcha/slider/2.jpg b/src/main/resources/static/captcha/slider/2.jpg new file mode 100644 index 00000000..d572cffd Binary files /dev/null and b/src/main/resources/static/captcha/slider/2.jpg differ diff --git a/src/main/resources/static/captcha/slider/3.jpg b/src/main/resources/static/captcha/slider/3.jpg new file mode 100644 index 00000000..6975d20c Binary files /dev/null and b/src/main/resources/static/captcha/slider/3.jpg differ diff --git a/src/main/resources/static/captcha/slider/4.jpg b/src/main/resources/static/captcha/slider/4.jpg new file mode 100644 index 00000000..8dafb765 Binary files /dev/null and b/src/main/resources/static/captcha/slider/4.jpg differ diff --git a/src/main/resources/static/captcha/slider/5.jpg b/src/main/resources/static/captcha/slider/5.jpg new file mode 100644 index 00000000..bc0ee233 Binary files /dev/null and b/src/main/resources/static/captcha/slider/5.jpg differ diff --git a/src/main/resources/static/captcha/slider/6.jpg b/src/main/resources/static/captcha/slider/6.jpg new file mode 100644 index 00000000..d5a959c2 Binary files /dev/null and b/src/main/resources/static/captcha/slider/6.jpg differ diff --git a/src/main/resources/static/captcha/slider/7.jpg b/src/main/resources/static/captcha/slider/7.jpg new file mode 100644 index 00000000..81d45619 Binary files /dev/null and b/src/main/resources/static/captcha/slider/7.jpg differ diff --git a/src/main/resources/static/captcha/slider/8.jpg b/src/main/resources/static/captcha/slider/8.jpg new file mode 100644 index 00000000..3ac5bbc8 Binary files /dev/null and b/src/main/resources/static/captcha/slider/8.jpg differ diff --git a/src/main/resources/static/captcha/slider/9.jpg b/src/main/resources/static/captcha/slider/9.jpg new file mode 100644 index 00000000..55ed2e4c Binary files /dev/null and b/src/main/resources/static/captcha/slider/9.jpg differ diff --git a/src/main/resources/views/platform/sys/login.html b/src/main/resources/views/platform/sys/login.html index 2898d2ac..75b45998 100644 --- a/src/main/resources/views/platform/sys/login.html +++ b/src/main/resources/views/platform/sys/login.html @@ -31,11 +31,17 @@ + + +
+
+ 拖动滑块,完成拼图 + 换一张 +
+
+ +
+ +
{{ captchaChecking ? '正在验证…' : captchaHint }}
+
+
@@ -97,18 +126,12 @@ + @@ -263,6 +510,8 @@ } .login-container { + position: relative; + z-index: 1; width: 100vw; height: 100vh; display: grid; @@ -291,6 +540,121 @@ width: 500px; } + .login-card { + width: 100%; + } + + .login-tabs { + display: flex; + gap: 32px; + justify-content: center; + margin-bottom: 28px; + border-bottom: 1px solid #edf0f5; + } + + .login-tabs button { + padding: 0 0 14px; + border: 0; + border-bottom: 3px solid transparent; + background: transparent; + color: #7c8798; + font: inherit; + font-size: 17px; + cursor: pointer; + } + + .login-tabs button.active { + border-bottom-color: #1867b0; + color: #1867b0; + font-weight: 600; + } + + .login-tabs button:disabled { + cursor: default; + } + + .captcha-dialog { + width: 400px; + max-width: calc(100vw - 32px); + border-radius: 6px; + } + .captcha-dialog .el-dialog__body { + padding: 10px 20px 20px; + } + .captcha-panel { + padding: 12px; + border: 1px solid #dcdfe6; + border-radius: 6px; + background: #f7faff; + line-height: 1.5; + } + .captcha-heading { + display: flex; + justify-content: space-between; + align-items: center; + color: #606266; + margin-bottom: 8px; + } + .captcha-picture { + position: relative; + padding-top: 50%; + overflow: hidden; + border-radius: 4px; + background: #e9eef5; + } + .captcha-background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + .captcha-piece { + position: absolute; + filter: drop-shadow(0 2px 3px rgba(0, 0, 0, .5)); + } + .captcha-picture img { + pointer-events: none; + user-select: none; + } + .captcha-range { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 40px; + margin: 12px 0 0; + border-radius: 5px; + background: #e4eaf2; + cursor: ew-resize; + touch-action: pan-y; + } + .captcha-range::-webkit-slider-thumb { + -webkit-appearance: none; + width: 42px; + height: 40px; + border-radius: 5px; + border: 2px solid #fff; + background: #409eff; + box-shadow: 0 1px 4px rgba(0, 0, 0, .2); + } + .captcha-range::-moz-range-thumb { + width: 38px; + height: 36px; + border-radius: 5px; + border: 2px solid #fff; + background: #409eff; + } + .captcha-range:focus-visible { outline: 2px solid #409eff; outline-offset: 3px; } + .captcha-range:disabled { opacity: .55; cursor: wait; } + .captcha-hint { margin-top: 6px; color: #909399; font-size: 12px; text-align: center; } + + .login-tip { + margin: -4px 0 0; + color: #8a94a4; + font-size: 12px; + line-height: 20px; + } + .font-semibold { font-weight: 600; } @@ -317,18 +681,6 @@ border-radius: 1.5rem; } - .login-code { - display: inline-block; - float: right; - width: 38%; - height: 38px; - } - - .login-code img { - vertical-align: middle; - cursor: pointer; - } - .login-bg-img { position: fixed; width: 100%; @@ -344,7 +696,32 @@ } .login-container .login-box .login-form { - width: 100vw !important; + width: 100% !important; + padding: 24px 20px; + box-sizing: border-box; + } + + .login-card { + padding: 30px 22px; + } + + .text-6xl { + font-size: 24px; + line-height: 1.5; + overflow-wrap: anywhere; + } + + .login-tabs { + gap: 26px; + } + + .login-tabs button { + font-size: 16px; + } + + + .el-input-group__append { + padding: 0 14px; } } diff --git a/src/main/resources/views/platform/zhgh/club/common/clubInfoComponent.js b/src/main/resources/views/platform/zhgh/club/common/clubInfoComponent.js index 98263f88..d95b32bb 100644 --- a/src/main/resources/views/platform/zhgh/club/common/clubInfoComponent.js +++ b/src/main/resources/views/platform/zhgh/club/common/clubInfoComponent.js @@ -8,6 +8,12 @@ const REGISTER_INFO_COMPONENT = { 点击查看流程图 + + + 暂无图片 + + 暂无图片 + {{viewData.clubName}} diff --git a/src/main/resources/views/platform/zhgh/club/join/apply/index.html b/src/main/resources/views/platform/zhgh/club/join/apply/index.html index 1256e05c..7bd95049 100644 --- a/src/main/resources/views/platform/zhgh/club/join/apply/index.html +++ b/src/main/resources/views/platform/zhgh/club/join/apply/index.html @@ -2,9 +2,19 @@ layout("/layouts/platform.html"){ #--> -
+ + +
- +
+ 返回 +
+ @@ -156,6 +166,9 @@ layout("/layouts/platform.html"){ } }, methods: { + backToClubList() { + commonUtil.pjaxPush('/platform/club/join/list'); + }, onSave() { this.$confirm("您确定保存吗?", "提示", { confirmButtonText: "确定", @@ -224,6 +237,16 @@ layout("/layouts/platform.html"){ this.$axios.post("/platform/club/common/listClub").then((res) => { if (res.code === 0) { this.clubOptions = res.data + // 从卡片进入新申请时预选社团,编辑已有申请仍使用原记录。 + const clubId = GetQueryString("clubId") + if (!this.bizId && clubId) { + const club = this.clubOptions.find(item => item.id === clubId) + if (club) { + this.$set(this.formData, "clubId", club.id) + this.$set(this.formData, "clubName", club.clubName) + this.checkApplyClub(club.id) + } + } } }) }, diff --git a/src/main/resources/views/platform/zhgh/club/join/list/index.html b/src/main/resources/views/platform/zhgh/club/join/list/index.html index 28f1fbf5..4320609d 100644 --- a/src/main/resources/views/platform/zhgh/club/join/list/index.html +++ b/src/main/resources/views/platform/zhgh/club/join/list/index.html @@ -11,9 +11,36 @@ layout("/layouts/platform.html"){ .el-tabs__header { margin: 0; } + .club-directory .club-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; } + .club-directory .club-card { display: flex; flex-direction: column; min-width: 0; overflow: hidden; border: 1px solid #e4edf8; border-radius: 10px; background: #fff; box-shadow: 0 7px 20px rgba(55, 85, 126, .08); } + .club-directory .club-card-main { flex: 1; min-width: 0; } + .club-directory .club-card-cover { width: 100%; height: 180px; overflow: hidden; background: #edf4fc; } + .club-directory .club-card-cover .el-image { width: 100%; height: 100%; } + .club-directory .club-image-empty { display: flex; flex-direction: column; gap: 8px; height: 100%; align-items: center; justify-content: center; color: #8da1ba; font-size: 13px; background: #edf4fc; } + .club-directory .club-image-empty i { font-size: 34px; } + .club-directory .club-card-info { min-width: 0; padding: 14px 16px; } + .club-directory .club-card-name { margin: 0; color: #303846; font-size: 16px; font-weight: 400; line-height: 24px; overflow-wrap: anywhere; } + .club-directory .club-card-meta { margin: 5px 0 0; color: #98a2b2; font-size: 13px; line-height: 20px; overflow-wrap: anywhere; } + .club-directory .club-card-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid #edf1f6; } + .club-directory .club-card-tag { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 4px; background: #edf5ff; color: #397de0; font-size: 12px; line-height: 18px; } + .club-directory .club-card-tag-label { margin-right: 4px; color: #8aa7cf; } + .club-directory .club-card-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 0 14px; margin: 0 16px; border-top: 1px solid #edf1f6; } + .club-directory .club-card-actions .el-button { height: 32px; margin: 0; padding: 0 14px; border: 0; border-radius: 16px; font-size: 13px; } + .club-directory .club-card-actions .el-button--primary { width: 108px; color: #fff; background: #1989fa; box-shadow: 0 5px 12px rgba(25, 137, 250, .18); } + .club-directory .club-card-actions .el-button--primary.is-plain { width: auto; color: #397de0; background: #edf5ff; box-shadow: none; } + .club-directory .club-card-actions .el-button--danger { width: 108px; color: #fff; background: #ee0a24; box-shadow: 0 5px 12px rgba(238, 10, 36, .18); } + .club-directory .club-card-sort { display: flex; justify-content: flex-end; margin-bottom: 16px; } + .club-directory .club-card-sort .el-select { width: 168px; } + .club-directory .club-detail-cover { width: 160px; max-width: 100%; height: 110px; overflow: hidden; border-radius: 8px; background: #f8fafc; } + .club-directory .club-detail-cover .el-image { display: block; width: 100%; height: 100%; border-radius: 8px; } + @media (max-width: 640px) { + .club-directory .club-card-grid { grid-template-columns: minmax(0, 1fr); } + .club-directory .club-card-cover { height: 150px; } + .club-directory .club-detail-cover { width: 120px; height: 90px; } + } -
+
- - + + + + + @input="selectFundSource"> 校工会经费 @@ -88,11 +97,11 @@ layout("/layouts/platform.html"){ - + - + {{item.name}} @@ -104,24 +113,6 @@ layout("/layouts/platform.html"){ {{formData.userName}}({{formData.loginName}}) - - - - - - - - - @@ -149,7 +140,7 @@ layout("/layouts/platform.html"){ placeholder="请输入联系方式"> - + @@ -273,7 +264,34 @@ layout("/layouts/platform.html"){ - + + + + + + + + + + + + + + + + + + @@ -399,7 +417,7 @@ layout("/layouts/platform.html"){ - + @@ -438,7 +456,7 @@ layout("/layouts/platform.html"){
报销金额
- +
@@ -587,7 +605,7 @@ layout("/layouts/platform.html"){
发票明细列表
- 发票选填;无发票时按支付信息汇总报销金额。上传后须通过验真,每条明细对应一张发票图片或单页 PDF。 + {{ isManualReimburseAmount ? '发票选填;有发票时报销金额不得超过发票总额,无发票仍可提交。' : '发票选填;无发票时按支付信息汇总报销金额。' }}上传后须通过验真,每条明细对应一张发票图片或单页 PDF。
@@ -821,7 +839,13 @@ layout("/layouts/platform.html"){ // invoiceNumber: [{required: true, message: "请填写发票张数", trigger: ["change", "blur"]}], // invoice: [{required: true, message: "请填写发票号码", trigger: ["change", "blur"]}], activityName: [{required: true, message: "请填写活动名称", trigger: ["change", "blur"]}], - money: [{required: true, message: "请填写报销金额", trigger: ["change", "blur"]}], + money: [{required: true, message: "请填写报销金额", trigger: ["change", "blur"]}, { + validator: (rule, value, callback) => { + const message = this.getReimburseAmountMessage() + callback(message ? new Error(message) : undefined) + }, + trigger: ["change", "blur"] + }], condolenceRelationship: [{required: true, message: "与被慰问人关系", trigger: ["change", "blur"]}], marryTime: [{required: true, message: "结婚时间", trigger: ["change", "blur"]}], fertilityTime: [{required: true, message: "生育时间", trigger: ["change", "blur"]}], @@ -835,8 +859,8 @@ layout("/layouts/platform.html"){ ], files: [{ validator: (rule, value, callback) => { - // 慰问项目不展示附件上传,提交时无需校验附件。 - if (this.formData.reimburseProject === "UNION_REIMBURSE_PROJECT_1") { + // 慰问项目无需校验附件,文体、日常和社团活动附件选填。 + if (["UNION_REIMBURSE_PROJECT_1", "UNION_REIMBURSE_PROJECT_2", "UNION_REIMBURSE_PROJECT_3", "UNION_REIMBURSE_PROJECT_5"].includes(this.formData.reimburseProject)) { callback() return } @@ -910,6 +934,16 @@ layout("/layouts/platform.html"){ } }, computed: { + isManualReimburseAmount() { + return ["UNION_REIMBURSE_PROJECT_2", "UNION_REIMBURSE_PROJECT_3", "UNION_REIMBURSE_PROJECT_4"] + .includes(this.formData.reimburseProject) + }, + // 社团经费固定对应社团活动,复用文体活动的基本信息和发票表单。 + availableReimburseProjects() { + const clubFund = this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3" + return this.reimburseProjectList.filter(item => + clubFund ? item.code === "UNION_REIMBURSE_PROJECT_5" : item.code !== "UNION_REIMBURSE_PROJECT_5") + }, // 按选中类型的业务编码控制字段,类型列表异步加载及旧申请回显后自动重新计算。 condolenceTypeCode() { if (this.formData.reimburseProject !== "UNION_REIMBURSE_PROJECT_1") return "" @@ -917,9 +951,11 @@ layout("/layouts/platform.html"){ const selectedType = this.typeOptions.find(item => item.id === typeId) return selectedType ? selectedType.code : "" }, - // 仅系统管理员和校工会管理员可查看真实余额,其他账号统一显示余额遮罩。 + // 社团经费按菜单授权查看年度剩余额度,其他经费沿用原余额可见范围。 canViewFundBalance() { return this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"]) + || (this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3" + && this.$auth.hasPermission("unionReimburse.club")) }, showInvoiceTab() { return !!this.formData.reimburseProject @@ -1549,6 +1585,27 @@ layout("/layouts/platform.html"){ this.rebuildInvoiceSummary() }) }, + // 手填金额按分比较票面总额;未上传发票时仍允许申请。 + getReimburseAmountMessage() { + if (!this.isManualReimburseAmount) return "" + const value = String(this.formData.money == null ? "" : this.formData.money) + if (!value) return "请填写报销金额" + const cents = this.moneyToCents(value) + if (!/^\d+(\.\d{1,2})?$/.test(value) || cents === null || cents <= 0) { + return "报销金额必须为正数,且最多两位小数" + } + const invoices = this.formData.invoiceDetails || [] + let invoiceCents = 0 + for (let i = 0; i < invoices.length; i++) { + const amount = this.moneyToCents(invoices[i] && invoices[i].invoiceAmount) + if (amount === null || amount <= 0) return "请填写正确的发票金额" + invoiceCents += amount + } + if (invoices.length && cents > invoiceCents) { + return "报销金额不能超过发票总额(" + (invoiceCents / 100).toFixed(2) + "元)" + } + return "" + }, rebuildInvoiceSummary() { let totalMoney = 0 let fileCount = 0 @@ -1573,10 +1630,18 @@ layout("/layouts/platform.html"){ }) totalMoney = paymentCents / 100 } - this.$set(this.formData, "money", totalMoney ? totalMoney.toFixed(2) : "0.00") + // 三类活动保留手填申请金额,发票及支付明细变化只更新张数。 + if (!this.isManualReimburseAmount) { + this.$set(this.formData, "money", totalMoney ? totalMoney.toFixed(2) : "0.00") + } this.$set(this.formData, "invoiceNumber", String(fileCount)) }, validateInvoiceDetailsBeforeSubmit() { + const amountMessage = this.getReimburseAmountMessage() + if (amountMessage) { + this.$message.warning(amountMessage) + return false + } const invoiceDetails = this.formData.invoiceDetails || [] // 发票可不上传;已经添加的发票仍须填写完整并沿用验真流程。 for (let i = 0; i < invoiceDetails.length; i++) { @@ -1904,6 +1969,7 @@ layout("/layouts/platform.html"){ }, // 根据报销项目获取对应的附件说明 getFileDescByProject(projectCode) { + if (projectCode === "UNION_REIMBURSE_PROJECT_5") return "社团活动附件说明" if (!this.descOptions || !Array.isArray(this.descOptions)) { return ''; } @@ -1918,21 +1984,20 @@ layout("/layouts/platform.html"){ const project = this.descOptions.find(item => item.reimburseProject === projectCode); return project ? project.projectTip : ''; }, - // 新建申请默认优先选择分工会经费;若当前用户没有对应权限,则按页面可见权限回退到其它经费来源。 + // 默认来源与角色允许的按钮一致,社团负责人默认进入社团活动申请。 getDefaultFundSource() { - if (this.$auth.hasPermission("unionReimburse.fgh")) { - return "UNION_REIMBURSE_FUND_SOURCE_2" - } - if (this.$auth.hasPermission("unionReimburse.xgh")) { - return "UNION_REIMBURSE_FUND_SOURCE_1" - } - if (this.$auth.hasPermission("unionReimburse.club")) { - return "UNION_REIMBURSE_FUND_SOURCE_3" - } - return "" + const option = this.budgetTypeOption.find(item => item.code === "UNION_REIMBURSE_FUND_SOURCE_2") + || this.budgetTypeOption.find(item => item.code === "UNION_REIMBURSE_FUND_SOURCE_3") + || (this.$auth.hasPermission("unionReimburse.xgh") && this.budgetTypeOption[0]) + return option ? option.code : "" }, selectFundSource(code) { this.$set(this.formData, "reimburseFundSource", code) + if (code === "UNION_REIMBURSE_FUND_SOURCE_3") { + this.$set(this.formData, "reimburseProject", "UNION_REIMBURSE_PROJECT_5") + } else if (this.formData.reimburseProject === "UNION_REIMBURSE_PROJECT_5") { + this.$set(this.formData, "reimburseProject", "UNION_REIMBURSE_PROJECT_2") + } if (code !== "UNION_REIMBURSE_FUND_SOURCE_3") { this.$set(this.formData, "clubId", null) this.$set(this.formData, "clubName", null) @@ -1986,9 +2051,13 @@ layout("/layouts/platform.html"){ this.fundBalanceText = "查询中..." this.$axios.post("/platform/unionReimburse/apply/getBudgetBalance", params) .then(response => { + // 切换社团后忽略旧请求,避免显示上一社团的余额。 + if (fundSource !== this.formData.reimburseFundSource + || (params.clubId && params.clubId !== this.formData.clubId)) return if (response.code === 0) { this.$set(this.formData, "fundBalance", response.data || 0) - this.fundBalanceText = (response.data || 0).toString() + this.fundBalanceText = fundSource === "UNION_REIMBURSE_FUND_SOURCE_3" + ? Number(response.data || 0).toFixed(2) : (response.data || 0).toString() } else { this.$set(this.formData, "fundBalance", null) this.fundBalanceText = response.msg || "查询失败" @@ -1996,6 +2065,8 @@ layout("/layouts/platform.html"){ } }) .catch(error => { + if (fundSource !== this.formData.reimburseFundSource + || (params.clubId && params.clubId !== this.formData.clubId)) return console.error("经费余额查询失败:", error) this.$set(this.formData, "fundBalance", null) this.fundBalanceText = "查询失败" @@ -2016,7 +2087,8 @@ layout("/layouts/platform.html"){ this.formData.hospitalizationEndTime ]); } - if (this.$auth.hasRoleOr(["SCHOOL_OUTLAY_ADMIN", "SCHOOL_UNION_ADMIN"])) { + if (this.$auth.hasRoleOr(["SCHOOL_OUTLAY_ADMIN", "SCHOOL_UNION_ADMIN"]) + && !this.$auth.hasRoleOr(["BRANCH_UNION_CHAIRMAN", "BRANCH_UNION_ADMIN", "CLUB_MANAGER", "CLUB_PRESIDENT"])) { this.budgetTypeOption.map(v => { if (["UNION_REIMBURSE_FUND_SOURCE_1"].includes(v.code)) { budgetTypeOption.push(v) @@ -2030,7 +2102,7 @@ layout("/layouts/platform.html"){ } }) } - if (this.$auth.hasRoleOr(["CLUB_MANAGER", "CLUB_PRESIDENT"])) { + if (this.$auth.hasPermission("unionReimburse.club")) { this.budgetTypeOption.map(v => { if (["UNION_REIMBURSE_FUND_SOURCE_3"].includes(v.code)) { budgetTypeOption.push(v) @@ -2039,11 +2111,22 @@ layout("/layouts/platform.html"){ } this.budgetTypeOption = budgetTypeOption } + // 社团经费按菜单权限过滤,默认来源与按钮可见范围保持一致。 + this.budgetTypeOption = this.budgetTypeOption.filter(item => + item.code !== "UNION_REIMBURSE_FUND_SOURCE_3" + || this.$auth.hasPermission("unionReimburse.club")) this.reimburseProjectList = await this.$businessTool.getDictOptions("UNION_REIMBURSE_PROJECT") + if (this.budgetTypeOption.some(item => item.code === "UNION_REIMBURSE_FUND_SOURCE_3")) { + const clubs = await this.$axios.post("/platform/unionReimburse/apply/listReimburseClubs") + this.clubOptions = clubs.data || [] + } if (this.bizId) { this.$axios.post("/platform/unionReimburse/apply/info", {id: this.bizId}).then(async (res) => { if (res.code === 0) { this.formData = this.ensureFormData(res.data) + if (this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3") { + this.$set(this.formData, "reimburseProject", "UNION_REIMBURSE_PROJECT_5") + } this.appendCurrentPayerOption() this.formData.paymentDetails.forEach(row => { if (row.payer) { @@ -2066,7 +2149,8 @@ layout("/layouts/platform.html"){ unionId: union?.id, unionName: union?.name, // reimburseType: "UNION_REIMBURSE_TYPE_1", - reimburseProject: 'UNION_REIMBURSE_PROJECT_1', + reimburseProject: this.getDefaultFundSource() === "UNION_REIMBURSE_FUND_SOURCE_3" + ? "UNION_REIMBURSE_PROJECT_5" : "UNION_REIMBURSE_PROJECT_1", reimburseFundSource: this.getDefaultFundSource(), paymentWay: 'UNION_REIMBURSE_PAYMENT_WAY_2', paymentDetails: [this.buildDefaultPaymentDetail({ @@ -2076,7 +2160,7 @@ layout("/layouts/platform.html"){ userId: id, }) if (this.formData.reimburseFundSource) { - this.reimburseFundSourceChange() + this.selectFundSource(this.formData.reimburseFundSource) } } } @@ -2085,17 +2169,7 @@ layout("/layouts/platform.html"){ this.init() this.queryFileDesc() this.queryCondolenceType() - //社团查询 - this.$businessTool.listClubByRole().then((res) => { - this.$set(this, "clubOptions", res || []) - if (res && res.length === 1 && !this.formData.clubId) { - this.$set(this.formData, "clubId", res[0].id) - this.$set(this.formData, "clubName", res[0].clubName) - } - if (this.formData.reimburseFundSource) { - this.reimburseFundSourceChange() - } - }) + } }) diff --git a/src/main/resources/views/platform/zhgh/dayofficework/unionReimburse/collect/index.html b/src/main/resources/views/platform/zhgh/dayofficework/unionReimburse/collect/index.html index f34e924d..1d244e97 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/unionReimburse/collect/index.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/unionReimburse/collect/index.html @@ -51,10 +51,10 @@ layout("/layouts/platform.html"){ + -