Compare commits

27 Commits

Author SHA1 Message Date
weicw1996 0bc66b5808 feat(hjc): 已支付订单下发标书附件,并堵住项目接口的标书地址泄露
「购买后才能下载标书」此前在服务端从未成立:`GET /api/hjc/bid-project/{id}` 与 `/list`
匿名即可拿到 `tenderFile`,订单详情也会回带 `project.tenderFile`;而订单列表接口
(`/hjc/order/my`)根本不带附件,PC 端连下载入口都没有。

- 新增 `HjcTenderFileUtil` + `HjcTenderFileVo`:把 `tender_file`(一站式推的 `files`:
  JSON 字符串数组 / 对象数组 / 逗号分隔)解析成 `{name,url}`;坏数据只丢弃、不抛异常
- `HjcOrder` 加非表字段 `tenderFiles`;`/hjc/order/my` 与 `/hjc/order/{id}` **只有
  `payStatus == 1`** 才附带(列表整批一次 `listByIds`,不 N+1;其余订单保持 null)
- 订单详情的 `project.tenderFile` 恒置 null:订单路径不留第二条地址来源
- `/hjc/bid-project/list` 一律不下发 `tenderFile`;`/hjc/bid-project/{id}` 仅 hjc 管理员
  (后台编辑表单要回填)或已为本项目付过款的买家可见,其余置 null,且**不返回 401**
  (详情对未登录是正常可用页面)
- 单测:`HjcTenderFileUtilTest`(12)、`HjcOrderControllerTenderFilesTest`(8)、
  `HjcBidProjectControllerDetailTest` 扩展 5 条(含既有 favorited/buyerCount 不回归)
  → `com.gxwebsoft.hjc.**` 共 147 条全通过

规格与验收:`.scratch/hjc-tender-download/`;两端前端同批改动见 hjc-web 2e847b5、hjc-h5 5412485
2026-09-17 18:09:50 +08:00
weicw1996 6d8185cc70 feat(hjc): 标书项目收藏:详情页收藏态 + 我的收藏
收藏归属**企业账号**(hjc_enterprise.id,全企业共享一份,ADR 0011),
且**不需要资质**——未认证/待审核/已驳回的企业都能收藏,资质只卡购买。

- 新表 hjc_project_favorite(enterprise_id, project_id, tenant_id, create_time,
  update_time),唯一键 (enterprise_id, project_id) 保证幂等;
  **没有 deleted 列**:取消收藏即物理删除(ADR 0012,先例 project_collection)。
- 接口:POST /api/hjc/project-favorite(幂等,并发撞唯一键按成功处理)、
  DELETE /{projectId}(幂等)、GET /page(我的收藏,收藏时间倒序)。
- 我的收藏每条由后端算好 saleState(onsale / ended / removed)与 purchased,
  口径只写一份;项目被下架或删除后**条目仍在**(LEFT JOIN 上刻意不加 b.deleted = 0),
  以「已下架」呈现而不是让收藏凭空消失。
- 标书详情内联 favorited(非表字段,未登录恒 false,不返回 401);
  purchased 复用「购买人数」的已付款口径。
- 排序:客户端排序**一律丢弃**。已在 dev 库实测:放行 ?sort=tenantId / ?sort=updateTime
  会得到「Column 'tenant_id' in order clause is ambiguous」的 500;而放行 ?sort=id
  更隐蔽——它不报错,只是把收藏时间倒序悄悄换成按 a.id 排。
- 单测 35 项:展示态判定、详情内联(未登录不得 401)、幂等、排序覆盖、
  越权覆盖(param.enterpriseId 强制覆盖)。
2026-09-17 04:07:36 +08:00
weicw1996 13cb08621f feat(hjc): 买家可取消待支付订单;标书详情「购买人数」改实时统计
取消订单(POST /api/hjc/order/cancel,幂等):
- 先向微信查单。SUCCESS 则**拒绝取消**并就地 markPaid 自愈(ADR 0009 的「以微信侧为准」);
  这是唯一能挡住「其实已付款、只是前端没送达」的时机
- 否则条件更新 order_status=2:
  UPDATE ... WHERE order_no=? AND pay_status=0 AND order_status=0
  靠**影响行数**保证幂等与安全,故新增 XML <update>——updateById 那条路拿不到行数
- 再尽力 closeOrder 关掉微信侧那张支付单,避免「已取消却仍能付款」;失败只记日志、不回滚
- 查单异常时仍放行取消,但响应带 verified=false,如实告知「未与微信核对」,不替微信下结论
- 响应恒 code=0 + 结构化 data{cancelled,paid,verified,payStatus,orderStatus}:
  「其实已经付了」不是错误,不该让前端靠 message 字符串判定
- 结果以**重新读到的状态**为准,不以条件更新返回值为准(并发重复取消 / 期间被支付)
- **不推送一站式**。出向状态只有 PAID/REFUNDED,且 buildCreatePurchaseDetails 对非退款单
  恒发 PAID——在取消路径误调会造出一条假的「已支付」采购记录。未支付订单从未被推送过

标书详情新增 buyerCount(非表字段,实时统计的已付款订单数):
原「购买人数」取自 saleCount——下单即加、从不回退,dev 实测项目 4 的 16 里 11 张根本没付款。
saleCount 字段与下单累加未动(已无展示用途),**零 DDL**。

/order/pay 失败响应带 data.wechatCode:从 PaymentException 的 cause 链取
ServiceException.getErrorCode()(SDK 原样挂在 cause 上,中途各级 catch 全是 throw e),
供前端识别跨端 OUT_TRADE_NO_USED。**公共支付层与 core 一行未改。**

测试:HjcOrderCancelUtilTest(9) + HjcOrderControllerCancelTest(12,含推送禁区回归守卫)
2026-09-17 03:39:53 +08:00
weicw1996 fdff54a6cf chore(hjc): 一站式出向推送联调探测脚本
与已入库的 scripts/hjc_test_push.py(入向:一站式 → 官网 /api/hjc/push/project)互为反向,
一条命令回答「出向对接通没通、卡在哪一层」:

- 按文档约定的签名方案(sign = MD5(appKey + password + timestamp))发起真实推送;
- 额外遍历若干鉴权头变体,用于区分「签名不对」与「根本没校验签名」;
- 用对照探测把失败定位到具体一层:登录过滤器 / 路由不存在 / 验签失败 / 网络不可达。

默认参数直接从仓库的 application.yml 读取,避免脚本与配置脱节。
2026-09-17 02:35:18 +08:00
weicw1996 6e475bea03 fix(core): 基础设施故障不再伪装成凭据失效
鉴权过滤器里除了「token 无效」,还有两次外部依赖调用——核心实例的 /auth/user 与 Redis 的域名
白名单读。此前它们抛出的异常与「token 无效」一起被兜底成 401「请退出重新登录」,于是线上出现
这样的报文:

  {"code":401,"message":"请退出重新登录","error":"...RedisException: SocketException: Connection reset"}

用户照着这句话去重新登录什么也解决不了——Redis 连接被重置跟他手里的 token 毫无关系;真正的故障
被「登录态异常」这层皮盖住,排查方向从一开始就被带偏;而且重新登录会把一份**完全有效**的凭据丢掉,
把一次秒级抖动放大成用户的重复劳动。

- 新增 AuthFailureUtil.isInfrastructureFailure(e):沿 cause 链找基础设施异常。必须沿链找,因为真实
  异常是套娃的(RedisSystemException → io.lettuce.core.RedisException → SocketException;
  IORuntimeException → ConnectException),只看最外层会漏判;凭据类异常
  (MalformedJwtException / SignatureException / UsernameNotFoundException)的 cause 链里不会出现
  这些类型。带遍历深度上限,防御自引用环。
- Constants 加 SERVICE_UNAVAILABLE_CODE(503) 与 SERVICE_UNAVAILABLE_MSG。
- JwtAuthenticationFilter:命中基础设施故障时返回 503 +「服务暂不可用,请稍后重试」,
  其余异常仍按凭据失效处理。
- 补 AuthFailureUtilTest / JwtAuthenticationFilterTest(tokenKey 运行时用 JwtUtil.randomKey() 生成,
  不含任何真实凭据)。

只改变「异常如何归类与报出」,不触碰正常路径与既有错误码。
2026-09-17 02:35:13 +08:00
weicw1996 a3c54249dc feat(hjc): 密码找回与修改密码(材料审核制 + 双因子)
密码找回(匿名接口,见 ADR 0008「密码重置由平台运维人工执行」)
- POST /api/hjc/auth/password/apply:企业名称 + 纳税人识别号 + 新密码 + 授权委托书。
  用双要素定位企业而不是只用企业名称——hjc_enterprise.name 没有唯一索引(hjc_init.sql 里只有
  user_id 唯一),只用名称会把 A 企业的密码重置落到 B 账号上;加上同样有索引的 credit_code 后
  歧义天然消失,命中多条时告警并拒绝。企业不存在与信息不匹配返回同一句话且 data 为 null,
  避免企业注册状态被枚举;同企业 24h 内二次提交返回已存在那条。
- GET /api/hjc/auth/password/apply/status:同样双要素查进度。
- 状态 0 待审核 / 1 已通过(待重置) / 2 已驳回 / 3 已重置。「已通过」≠「已重置」是刻意的:
  核心实例没有任何 hjc 可用的代改密码通道,最后由运维照单在核心实例后台手工重置,再回后台标记。
- 新表 hjc_password_apply:hjc_init.sql 加建表语句,另给幂等的 hjc_password_apply.sql 作发布步骤
  (只增表,不动任何既有表,对共用本库的其他项目无影响)。新密码明文存储供运维照单重置,
  是 ADR 0008 已显式接受的风险,列注释里写明。

修改密码(登录态,双因子:旧密码 + 账号绑定手机号的短信验证码)
- POST /api/hjc/auth/password/sms 与 PUT /api/hjc/auth/password/change。短信发到**账号绑定手机号**
  (后端从登录态取),前端不能指定号码。
- HjcCoreAuthClient 加两条调用,且两条的认证姿态刻意相反,各自的「为什么与类注释相反」都写在注释里:
  verifyOldPassword 必须转发买家 Authorization(核心实例 PUT /auth/password 按登录态识别改谁),
  resetPassword 必须剥掉 Authorization 且带 userId + tenantId——不传 userId 时核心实例会按手机号
  查出**所有租户**下的账号并逐个改密(其 SQL 无 tenant_id 条件),那会连带改掉同一手机号在其它
  平台的账号密码。

访问控制
- SecurityConfig 只把匿名的 apply 加进 permitAll(status 走既有 GET /** 放行);sms 与 change
  **必须**保持登录态,故刻意不入名单。

后台端点(配套 hjc-vue 的审核页)
- /api/hjc/password-apply 的 page / detail / audit / mark-reset 四个管理员端点。列表不带明文新密码,
  只有详情给(运维要照单重置)。

未验:端到端需真实环境(核心实例授权、审核人、运维重置),见 .scratch/hjc-password/issues/06。
2026-09-17 02:35:02 +08:00
weicw1996 2cd60270b9 feat(mp-java): hjc 小程序接入链、微信接入自检、首页轮播与一站式出向推送
hjc 包的进展(按 AGENTS.md 约定,改动集中在 hjc 包内):

- HjcWechatController 新增三接口:
  - GET  /api/hjc/wechat/readiness  管理员自检,逐项报告小程序/公众号/支付/serverUrl
    是否配好、支付 appId 与小程序 appId 是否一致;?probe=true 实调微信验证 appSecret
    (失败只记日志、不抛异常)。appId 与商户号在 detail 里打码,appSecret 永不输出。
  - GET  /api/hjc/wechat/mp-appid   只回 appId,供 H5 渲染开放标签(不含密钥)。
  - POST /api/hjc/wechat/mp-login   uni.login 的 code 换小程序 openid/unionid;匿名放行,
    租户取 HjcAuthProperties 的配置值而非可伪造的请求头。
  配套 HjcWechatReadinessUtil(含单测)与 HjcWechatController 的两套配置读取:
  公众号 cache{t}:setting:wx-official、小程序 mp-weixin:{t} → setting:mp-weixin:{t} →
  跨库回源 gxwebsoft_core.sys_setting。
- HjcBannerController / Service / ServiceImpl / Mapper(+XML) / HjcBannerVo:只读
  GET /api/hjc/banner/list,按租户 + position + 启用状态 + 生效时间窗口过滤 CMS
  轮播组并扁平化;租户隔离交给 MyBatis-Plus 租户拦截器,不改 cms 及其他项目代码。
  配套 HjcBannerApiTest(最小上下文 MockMvc,含不串租户与位置过滤)。
- 一站式出向推送:CreatePurchaseDetails 补退款字段,HjcOrder 补 refund_time /
  refund_reason(配套 hjc_order_add_refund.sql 与 hjc_init.sql),HjcBizServiceImpl
  在 REFUNDED 时把退款时间与原因一并推送;配套推送报文单测。
- 共享文件 SecurityConfig.java 仅追加一行:匿名放行 /api/hjc/wechat/mp-login
  (注册页证件上传与 OCR 早先已放行)。这是本包唯一改到 common 的地方。
- 文档:CONTEXT.md、docs/一站式平台对接-接口文档.md。

**不含** scripts/hjc_test_push_out.py:该联调探针脚本内含与 HjcOneStopAuthUtil.java
相同的 APP_KEY / PASSWORD 明文,按此前排查记录「不得提交」处理,保留在工作区未跟踪。
2026-09-14 19:01:47 +08:00
weicw1996 b2b1a1bdf9 fix(payment): 微信支付改用公钥模式,修「无可用的平台证书」404
商户 1116982020 已在商户平台申请使用微信支付公钥,/v3/certificates 对这类
商户返回 RESOURCE_NOT_EXISTS(404)「无可用的平台证书」;而 WxPayConfigService
无条件使用 RSAAutoCertificateConfig(构造时即下载平台证书),因此创建支付
配置必然失败,Native/JSAPI 下单、查询、退款全部不可用。

- 数据库配置了 pub_key_id 时改用 RSAPublicKeyConfig(不请求平台证书):
  dev 取 classpath dev/wechat/{租户}/pub_key.pem,prod 取证书卷路径 pubKey
- 配了公钥ID但公钥文件缺失时抛出明确错误(含预期路径),
  不再退回平台证书自动下载后抛出难以定位的 404
- application-dev.yml 公钥文件名 wechatpay_cert.pem → pub_key.pem,
  与仓库内实际文件一致(shop 下单流程的 dev 公钥分支同受益)
- clearConfigCache 同时清 Redis Payment:1:{tenantId}:此前库中改了公钥ID
  也要等最长 24 小时缓存过期才生效
- 测试控制器 /api/test/clear-payment-cache 清的键不对(Payment:0:*),
  改为调用 clearConfigCache
- 新增 WxPayConfigServicePublicKeyTest 回归测试(3 个用例)

验证:/api/payment/query 由「创建微信支付配置对象失败: Wrong HttpStatusCode[404]
无可用的平台证书」变为微信业务应答 ORDER_NOT_EXIST;并用真实响应头验证
Wechatpay-Serial=PUB_KEY_ID_… 的公钥签名校验 PASS。
2026-09-12 18:47:59 +08:00
weicw1996 da1f9a64d4 fix(hjc): 短信验证码发送成功却报「服务暂不可用」——data 不是对象时解析就炸
CoreResult.of 用 json.getJSONObject("data") 取 data。fastjson 见到 String 值会
当成 JSON 再解析一次,而核心实例 ApiResult.data 是 Object,/sendSmsCaptcha 成功
时给的就是阿里云返回的字符串 "OK"(core 的 MainController 是
success("发送成功", result.get("Message"))),解析 "OK" 抛 JSONException。

该异常发生在 of() 内部,不在那个只包住 parseObject(raw) 的 try 里,于是逃到
execute() 的兜底 catch,被改写成 code=-1「认证服务暂不可用,请稍后重试」。
实际后果是短信已经发出去了,用户和前端看到的却是发送失败:核心实例侧验证码已
写入 Redis、60 秒重发限制与当日 10 条计数均已消耗,用户一重试就撞上
「发送过于频繁」,看起来更像坏了。login/register/captcha 的 data 都是对象,
所以只有发送短信这一条路坏。

- data 改为只接受对象,取不到即 null,绝不抛异常(toObjectData 保留 Map 分支,
  兼容 fastjson 桥接层给出的普通 Map);调用方本就只按 code 判成败,明细读 raw
- 新增 HjcCoreAuthClientResultTest:用日志里的真实响应体走完整调用链
  (sendSmsCaptcha → post → execute → of,只打桩 doExecute 出口)。
  改前该用例复现出逐字相同的异常(栈同在 of(HjcCoreAuthClient.java:93)),改后绿
- 用例同时锁住:data 为对象(登录/验证码)、业务失败无 data 键、data 为数字/数组、
  响应根本不是 JSON(网关 HTML)四种情形
- application.yml:hjc.one-stop.base-url 由空串改为 http://180.141.88.21:8810
  (base-url 为空时一站式推送不会发起,此前上线一直缺这项配置)
- 未改动核心实例代码,改动面限于 hjc 包与上述配置
2026-09-11 01:07:34 +08:00
weicw1996 27dbea9b6f fix(hjc): 买家接口未登录统一返回 401,越权返回 403(此前与业务失败同为 code=1)
共享 SecurityConfig 里 GET /** 放行,未登录的 GET 会进到 controller 由 controller
自行判登录态;此前这些地方返回 fail("用户未登录") 即 code=1,与业务失败同码,
前端无法据此判定登录失效——PC 端会把它显示成一句错误提示,而不是清凭据回登录页。

- 新增 HjcAuthResponses:unauthorized() → 401「请先登录」,
  forbidden(msg) → 403;复用平台既有常量 Constants.UNAUTHENTICATED_CODE/_MSG
  (这两个常量本就为此准备,此前唯一引用处是被注释掉的代码),不新造码值
- 7 处未登录返回改为 401:enterprise/my、enterprise/save、
  order/create、order/pay、order/mark-paid、order/my、order/{id}
- 订单详情的「无权查看该订单」改为 403,与 @PreAuthorize 的 AccessDeniedException
  归为同一个码,前端只需记一套规则(401 清凭据跳登录、403 只提示)
- 新增 HjcAuthResponsesTest 锁定契约(实测把码值改回 1 则测试红)
- 未改动共享 BaseController 与 SecurityConfig,改动面限于 hjc 包
2026-09-11 00:03:32 +08:00
weicw1996 ce1d0a2a8a feat(hjc): 登录认证重做——账号复用核心实例,企业语义自持;后台接口鉴权收口
汇吉采企业账号原由 hjc 自行写入 sys_user,注册与登录从未跑通:UserMapper 的
LEFT JOIN shop_user_oauth 既漏库名又是 core 2023 年的旧表名(打到不存在的
modules.shop_user_oauth),saveUser 因实体缺 schema 打到不存在的
modules.sys_user;即便修好,token 经共享过滤器远程回核心实例取用户时也必然 401。
本次按 ADR-0006(取代 ADR-0005)切分:账号归核心实例,企业归汇吉采。

- 账号本体是核心实例 sys_user 的一条零企业语义普通账号(type=0,不写
  shop_name/audit_status/company_id);注册/登录/图形验证码/短信 全部代理核心实例
- 企业主体、资质、经办人、证件、授权仍只存 hjc_enterprise / hjc_enterprise_material
- 新增 HjcCoreAuthClient:必带 TenantId 头(缺它 core 会拼 tenant_id=NULL,查重
  静默失效、登录永不匹配);剥掉 Authorization(否则短信码 Redis 前缀错位);
  不转发邮箱(core 会把明文密码邮件发给客户);登录不传 isSuperAdmin、发短信不传 scene
- 新增 HjcAuthProperties(hjc.tenant-id) 与 HjcAdminGuard:按 principal 的角色 +
  租户判定 hjc 管理员(core 权限串无 hjc 命名空间且本仓对 core 只有 SELECT)
- 鉴权收口(此前 hjc 包 @PreAuthorize 0 处):后台接口补权限校验,
  enterprise/page 等 GET 此前匿名可读、审核/退款仅需登录不校验角色;
  订单详情补归属校验,并先判身份再判存在(否则匿名可枚举订单 ID)
- 修复:注册在 core 建号成功后,因反序列化 UserDetails 多余字段
  (enabled/accountNonExpired/... 及 Role.sortNumber) 失败而被误报「注册失败」,
  造成「账号已存在但用户被告知失败、重试撞手机号已存在」的孤儿账号。
  改为忽略未知字段,且注册流程只取 userId、不再依赖整个 User 能否解析;
  并加回归测试(用真实失败响应体,反射读控制器真实的 ObjectMapper)
- 未改动核心实例代码,未触碰共用 mapper 的陈旧缺陷(仅 hjc 包与 SecurityConfig
  的 hjc 白名单为改动面)
2026-09-10 23:44:02 +08:00
weicw1996 5d88684820 fix(hjc): 证件上传改为存入 OSS(此前只写本地盘、返回的地址打不开)
问题:上传接口只把文件写进本地磁盘,再把地址拼成 {bucketDomain}{path} 或
{file-server}/api/file{path}——文件从未进过 OSS;前者指向桶里不存在的对象,
后者该前缀在文件服务主机上是 nginx 404。两条分支给出的地址都打不开,
证件预览坏、OCR 取图也必然失败。

做法(照后台 hjc-vue 的姿势):新增 HjcOssUploadUtil,把文件转交统一 OSS 上传服务
POST {config.server-url}/oss/upload(multipart file + tenantId 头,匿名可用),
取 data.path(原图,避开 data.url 的 x-oss-process 缩略参数)作为地址。
- 不转发调用方的 Authorization:hjc 的 JWT 与核心后端不是同一套签名,转过去会被判 401
- part 的 Content-Type 保留为图片类型(否则 OSS 存成 octet-stream,浏览器变下载、
  且 OCR 取图的 image/* 校验会拒掉自家上传的图)
- 上传失败即整个上传失败,不再回退成打不开的地址
- 未改动 common 的 FileController(多项目共用代码)

新增打桩单测 10 个(请求形状、path 优先、缩略参数剥离、失败码/非 JSON/无地址、类型嗅探)。
2026-09-10 14:47:06 +08:00
weicw1996 1e2ec82c18 feat(hjc): 证件OCR改用百度云(身份证/营业执照识别)
- 新增 com.gxwebsoft.hjc.ocr:HjcBaiduOcrClient(access_token 进程内缓存+提前刷新、
  两个接口调用、error_code/log_id 解析)、HjcOcrImageFetcher(取回 fileUrl 字节,
  要求 HTTP 200 且 Content-Type 为 image/*)、HjcOcrException(正数=百度云错误码,负数=本方错误码)
- HjcOcrServiceImpl 重写:材料类型路由(国徽面/授权委托书提前返回)、中文 key 映射、
  未识别哨兵值「无」过滤、图片大小与最短边前置校验(不合规不调百度云,身份证失败也计费)、
  失败按 error_code 打结构化日志(不记录识别出的值)
- pom 移除 aliyun-java-sdk-ocr;配置 aliyun.ocr.* 改为 baidu.ocr.api-key/secret-key(占位空值)
- 新增打桩单测 28 个(token 缓存与刷新、字段映射、「无」过滤、错误码、超限、正反面传错)

背景:阿里云 OCR 的 AK 一直是空占位,从未开通、链路从未跑通;甲方要求改用百度云。
接口契约不变(POST /api/hjc/ocr/recognize,前端零改动)。
2026-09-10 14:47:00 +08:00
weicw1996 08138f1f04 feat(hjc): 企业注册一站式——证件材料同事务入库 + 阿里云OCR识别 + 匿名注册上传
- HjcEnterprise 新增 idCardNo(经办人身份证号),配套迁移:
  src/main/resources/sql/hjc_enterprise_add_id_card_no.sql(需在目标库执行)
- HjcAuthRequest 新增 idCardNo / materials;/hjc/auth/register 同事务保存 4 类证件材料,
  并按「企业联系电话、企业地址选填,其余必填」补充后端校验
- 新增 POST /hjc/auth/upload:注册页登录前的匿名证件上传(复用 FileServerUtil,
  按 setting:upload 决定自建文件服 / OSS 域名;不写 file_record 表)
- 新增 POST /hjc/ocr/recognize:阿里云文字识别 OCR(aliyun-java-sdk-ocr 1.0.6)
  · idcard_front → agentName / idCardNo
  · license → creditCode / address(企业名称与法定代表人仅返回,供前端只读核对)
  · AK 走 aliyun.ocr.*,当前为占位空值;未配置时返回失败,由前端提示手动填写、不阻断注册
- SecurityConfig:/hjc/auth/upload 与 /hjc/ocr/recognize 匿名放行(与后台 /file/upload 隔离)
2026-09-10 13:12:10 +08:00
weicw1996 9d188d304b refactor(hjc): 发布时间/投标截止与开售/截止同义,仅保留 tenderOnsaleTime/tenderOffsaleTime 映射入库
- 撤销 HjcOneStopProjectPush 新增的 publishTime/deadlineTime 字段
- upsert 时 publishTime=tenderOnsaleTime, deadlineTime=tenderOffsaleTime(同一语义直接映射)
2026-09-09 17:10:17 +08:00
weicw1996 1aa8b8d4e4 fix(hjc): 入向推送补写发布时间/投标截止时间(无显式字段时取开售/截止时间兜底)
- HjcOneStopProjectPush 新增可选 publishTime/deadlineTime
- upsertFromPush: publishTime=显式publishTime||tenderOnsaleTime, deadlineTime=显式deadlineTime||tenderOffsaleTime
2026-09-09 17:04:41 +08:00
weicw1996 32a41bde6d feat(hjc): 支持公众号H5 JSAPI支付 + 微信网页授权/JS-SDK签名
- 新增 WechatJsapiStrategy(WECHAT):wechatpay-java JsapiServiceExtension.prepayWithRequestPayment
  返回 wx.chooseWXPay 所需参数;带 openid 走 JSAPI,否则回退 Native 扫码
- PaymentServiceImpl.getPaymentStrategy:WECHAT 按 openid 有无分流 JSAPI/Native
- HjcOrderController.pay:读取 openid,改用 WECHAT 类型
- 新增 HjcWechatController:/authorize 生成公众号授权地址、/oauth/callback code换openid回跳、
  /jsapi-sign 生成 wx.config 签名(jsapi_ticket,公众号配置存 redis cache{tenantId}:setting:wx-official)
- ADR 0003 记录 JSAPI 决策
2026-09-09 16:38:16 +08:00
weicw1996 9b050c9dc5 test(hjc): 一站式入向推送测试脚本(POST /api/hjc/push/project,appKey+timestamp+sign 鉴权) 2026-09-09 10:59:00 +08:00
weicw1996 32a3d1918c docs(hjc): 一站式平台对接接口文档(入向接收 + 出向推送 + 鉴权/字段映射/配置/待确认) 2026-09-09 10:48:59 +08:00
weicw1996 ab2f615067 feat(hjc): 后台订单退款接口 - POST /api/hjc/order/refund(置已退款+推送REFUNDED) 2026-09-08 23:19:52 +08:00
weicw1996 10099a1d5a feat(hjc): 网站企业账号+密码 注册/登录(JWT签发)
- POST /api/hjc/auth/register:创建 common.system.User(用户名=企业名称, BCrypt密码)+ 角色 + hjc_enterprise(待审核),签发 token(LoginResult)
- POST /api/hjc/auth/login:校验企业名称+密码,签发 token
- 复用 JwtUtil/JwtSubject/userService 与 wx-login 同一套签发;SecurityConfig 放行 /api/hjc/auth/register|login
2026-09-08 22:39:07 +08:00
weicw1996 4cbd925544 feat(hjc): 阶段2 订单支付闭环 - 下单后发起微信Native支付并回推一站式
- HjcOrderController: POST /api/hjc/order/pay(复用 payment.createPayment 取codeUrl)、PUT /api/hjc/order/mark-paid(幂等置已付+触发一站式推送)
- HjcBizService.markPaid: 置 payStatus=1/orderStatus=1/payTime,支付成功即 pushOrderToOneStop
- 出向推送在 base-url 未配置时不发起HTTP,仅记日志待重试
2026-09-08 20:54:01 +08:00
weicw1996 b2f310b81d feat(hjc): 汇吉采标书购买后端 阶段0/1 - 领域表/实体与接口 + 一站式双向对接
- 新增 com.gxwebsoft.hjc 包(entity/param/mapper/service/controller/dto/util)
- 实体:HjcBidProject(项目即标书单表)/HjcEnterprise/HjcEnterpriseMaterial/HjcOrder/HjcOrderPushLog
- CRUD 控制器:标书项目(公开列表+后台维护)、企业资质(提交/我的/审核)、订单(下单/我的订单/后台)
- 入向:POST /api/hjc/push/project 一站式推送(对称鉴权 appKey+timestamp+sign) 按 projectNo upsert
- 出向:createPurchaseDetails 推送客户端(MD5签名+推送日志+30min重试),手动触发端点
- SecurityConfig 放行 /api/hjc/push/project;application.yml 增加 hjc.one-stop 配置
- DDL:src/main/resources/sql/hjc_init.sql
2026-09-08 20:27:28 +08:00
weicw1996 1bcb805d65 docs(hjc): 甲方确认项目与标书为同一实体(一个项目一本标书),单表建模 2026-09-08 20:11:25 +08:00
weicw1996 770aeff4d7 docs(hjc): 汇吉采标书购买领域词表、推送鉴权规范与ADR 2026-09-08 20:09:41 +08:00
weicw1996 79666898c5 feat(ai-house): 解除房源与资料库强绑定 2026-08-08 06:48:09 +08:00
weicw1996 e362291d6e feat(house): 重构AI找房匹配能力 2026-08-08 03:12:57 +08:00
177 changed files with 25203 additions and 658 deletions
+43
View File
@@ -0,0 +1,43 @@
# 汇吉采标书购买平台
汇吉采标书购买平台是一站式代理平台的交易出口:企业账号在此注册、通过资质审核后购买标书,平台内完成订单与支付,并把购买记录推送回一站式平台。
## 核心实体
**标书项目 (Bid Project)**:
一条采购项目,同时也是可购买下载的标书,二者是**同一实体**(甲方确认:一个项目即一本标书)。携带项目编号、名称/分类、招标人、中标公司、中标金额与中标公告信息,以及标书价格(信息服务费)、标书附件、开售/停售时间与售卖方式。
_Avoid_: 项目(单用), 标书(单用), tender
**企业账号 (Enterprise Account)**:
买家账号,用企业名称+密码登录,须通过资质审核才能购买标书。
_Avoid_: 用户, 会员, buyer, hjc_user
**资质认证 (Qualification)**:
企业注册后提交、由平台审核的资质材料,状态为 待审核/已通过/已驳回(含驳回原因)。未通过不可下单。
_Avoid_: 认证
**订单 (Order)**:
企业购买标书的记录,含标书信息/数量/金额/购买企业,需支付后完成。
_Avoid_: 交易, purchase
**信息服务费 (Service Fee)**:
购买标书支付的金额,即「标书价格」。平台对每一本标书设定。
_Avoid_: 标书价格, 标书费
## 与一站式平台的关系
**一站式代理平台 (One-stop Platform)**:
上游系统,向本平台推送中标公告数据,并接收本平台推送的购买记录/订单落盘。
_Avoid_: 甲方, 上游
**中标公告 (Award Announcement)**:
项目流程走到「发布中标公告」时由一站式平台推送的公告数据,含招标人/中标公司/中标金额/公告正文/公告附件。
_Avoid_: 公告, bulletin
**售卖方式 (Selling Method)**:
标书的售卖渠道标识:1 公司财务 / 2 公众号 / 3 交易中心 / 4 政采云。
_Avoid_: 渠道
**推送 (Push)**:
本平台与一站式平台之间交换数据的动作:接收中标公告(入),并在订单支付成功后推送购买记录(出)。
_Avoid_: 同步, 对接
+3
View File
@@ -0,0 +1,3 @@
# 一站式推送鉴权:appKey + timestamp + signpassword 不进报文
汇吉采 → 一站式平台的订单推送采用 `appKey + timestamp + sign` 请求头鉴权,`sign = MD5(appKey + password + timestamp)`(小写),`password` 只作为密钥参与签名、不放入任何报文字段;一站式侧校验 appKey 匹配、timestamp 与本地时间差 ≤ ±10 分钟(防重放)、以及重算 MD5 与 sign 不区分大小写比对。原始需求文字要求把 `password` 明文加入请求头,会泄露共享密钥,本方案保留其 appKey/password/年月日时分/MD5 全部要素,仅把 password 降级为纯签名密钥,是相对原始字面的有意偏离;若一站式平台已按原始明文方案实现,兼容退路见 `docs/一站式平台推送-接口鉴权规范.md` 附录 A。理由:这是对外契约,改动成本高,且偏离点是刻意且必须记录,否则后人会「修复」回明文方案。
@@ -0,0 +1,5 @@
# 项目与标书为同一实体:一个项目一本标书
**Supersedes**: 早先「项目/标书两个实体、1 项目:N 标书」的假设——甲方已明确**项目与标书是一个东西,一个项目即一本标书**,故撤销该假设。
项目(Project)与标书(BidDocument)建模为**同一个实体**`标书项目`),单表承载。一站式推送的公告类字段(公告标题/招标人/中标公司/中标金额/公告正文/公告附件)与销售类字段(标书价格/附件/开售/停售/售卖方式/needSell)都落在这一张表上,由「推送入库 + 管理后台手工增删改查」双路填充。理由:既然甲方确认一个项目仅对应一本标书,两实体之间的 1:N 关系已不存在,拆表只会增加复杂度而无对应业务收益;等未来确实出现「一项目多标书」再演进(届时把标书款从主表抽出即可,主表结构无须预埋)。
+12
View File
@@ -0,0 +1,12 @@
# H5 端(公众号内)支付采用微信 JSAPI 直接唤起
汇吉采移动端 H5`hjc-h5`,在微信「公众号」内打开)的支付采用**微信 JSAPI**(`PaymentType.WECHAT` + `openid`),由后端 `WechatJsapiStrategy``wechatpay-java``JsapiServiceExtension.prepayWithRequestPayment` 直接返回前端 `wx.chooseWXPay` 所需的 `appId/timeStamp/nonceStr/package/signType/paySign`;无 `openid`(非公众号环境)时**回退 Native 扫码**展示 `codeUrl`
选择理由:公众号内直接「唤起支付」免去另开微信扫二维码的操作,转化率与体验均优于 Native 扫码;后端支付组件(`PaymentRequest.openId``PaymentResponse.wechatJsapi``PaymentChannel.WECHAT_JSAPI``WechatPayType.JSAPI`)早已为 JSAPI 预留,只差一个策略实现,改造成本低。
代价/偏离:
- **需要用户 openid**,因此引入微信**网页授权**(静默 `snsapi_base`):`/hjc/wechat/authorize` 生成授权地址,`/hjc/wechat/oauth/callback``code``openid` 后回跳 H5 并附带 `openid`
- **需要 `wx.config` 签名**:新增 `/hjc/wechat/jsapi-sign`(基于 `jsapi_ticket` 做 SHA1 签名),公众号配置(appId/appSecret)存于 Redis `cache{tenantId}:setting:wx-official`
- **不改 Native 扫码逻辑**:两者共用同一商户号/回调地址;`PaymentServiceImpl.getPaymentStrategy``WECHAT` 类型下按 `openid` 有无分流 JSAPI/Native,未破坏既有 Native 调用方。
兼容与说明:本方案是「带 openid 走 JSAPI,否则 Native」的弹性分流,非公众号环境(如普通浏览器/开发联调)仍可扫码支付,便于无真实公众号配置时验证。
+260
View File
@@ -0,0 +1,260 @@
# 汇吉采 ↔ 一站式代理平台 对接接口文档
> 版本:v1.0
> 适用:汇吉采标书购买平台(下称「官方网」)与一站式代理平台(下称「一站式」)之间双向数据互通。
> 相关:鉴权规则见 `docs/一站式平台推送-接口鉴权规范.md`;建表见 `src/main/resources/sql/hjc_init.sql`。
---
## 0. 概述
| 方向 | 触发时机 | 接口 | 位置 |
|---|---|---|---|
| **入向**(一站式 → 官方网) | 项目流程走到「发布中标公告」时 | `POST {官方网}/api/hjc/push/project` | 官方网接收标书/中标公告数据,按 `projectNo` upsert 落库 |
| **出向**(官方网 → 一站式) | C 端购买标书**支付成功后** / 退款时 | `POST {一站式}/api/biz/createPurchaseDetails` | 官方网把购买记录/订单信息推送到一站式落盘 |
**共享凭证**(双方约定,不通过网络传输明文密钥):
| 参数 | 值 | 说明 |
|---|---|---|
| `appKey` | `HJC_Official_Website` | 调用方标识,明文放请求头 |
| `password` | `vQ8$kR3#mW6@xP2!nF` | 签名密钥(Secret),仅参与签名,**不进报文** |
---
## 1. 鉴权规范(双向通用)
请求头携带:
| Header | 示例 | 说明 |
|---|---|---|
| `appKey` | `HJC_Official_Website` | 双方一致 |
| `timestamp` | `202604080103` | 格式 `yyyyMMddHHmm`(24 小时,北京时区,精确到分) |
| `sign` | `9f7c…(32位小写hex` | 签名值 |
**签名算法**`sign = MD5(appKey + password + timestamp)`(无分隔符,小写 32 位)。
**校验顺序**(任一步不过即拒绝,建议返回 `401`/`400`):
1. `appKey == HJC_Official_Website`
2. `timestamp` 与接收方服务器当前时间差 **≤ ±10 分钟**(防重放)
3. 用约定 `password` 重算 `MD5(appKey+password+timestamp)`,与 `sign` 不区分大小写比对
> 完整规则、示例(Python/Java)与「明文 password 头」兼容退路见 `docs/一站式平台推送-接口鉴权规范.md`。
---
## 2. 入向接口:一站式 → 官方网(接收标书/中标公告)
### 2.1 接口地址
```
POST {官方网BaseUrl}/api/hjc/push/project
```
> 官方网侧需在 SecurityConfig 放行该路径(已内置,`/api/hjc/push/project` 为公开接口,鉴权由本接口的 appKey/sign 自行校验)。
### 2.2 请求头
| Header | 值 |
|---|---|
| `appKey` | `HJC_Official_Website` |
| `timestamp` | `yyyyMMddHHmm` |
| `sign` | `MD5(appKey+password+timestamp)` |
| `tenantId` | `10626`(官方网当前固定租户,**必传**,作为落库租户) |
| `Content-Type` | `application/json` |
### 2.3 请求体字段
| 字段 | 类型 | 必填 | 说明 | 对应内部字段 |
|---|---|---|---|---|
| `projectNo` | string | ✔ | 项目编号 | `project_no`upsert 主键) |
| `projectName` | string | ✔ | 项目名称 | `project_name` |
| `tenderPrice` | number | | 标书价格/信息服务费 | `tender_price` |
| `files` | string(JSON/逗号分隔) | | 标书附件 | `tender_file` |
| `tenderOnsaleTime` | string | | 开售时间 `yyyy-MM-dd HH:mm:ss` | `onsale_time` |
| `tenderOffsaleTime` | string | | 停售时间 `yyyy-MM-dd HH:mm:ss` | `offsale_time` |
| `bulletinName` | string | | 公告标题 | `bulletin_title` |
| `customerName` | string | | 招标人 | `tenderer` |
| `supplierName` | string | | 中标公司 | `winner_supplier` |
| `bidAmount` | number | | 中标金额 | `bid_amount` |
| `content` | string | | 公告正文 | `bulletin_content` |
| `fileList` | string(JSON/逗号分隔) | | 公告附件 | `bulletin_file_list` |
| `needSellTender` | number | | 是否卖标书 0/1 | `need_sell` |
| `sellingMethod` | number | | 售卖方式 1公司财务 2公众号 3交易中心 4政采云 | `selling_method` |
### 2.4 响应
```json
{
"code": 0,
"message": "接收成功",
"data": { "id": 12, "projectNo": "ZB-2026-0001", "projectName": "xxx", "dataSource": "PUSH", "status": 1 }
}
```
- `code == 0` 成功;`code != 0` 失败(如 `message=鉴权失败` / `租户ID不能为空` / `项目编号不能为空`)。
### 2.5 落库规则(官方网侧)
-`projectNo` **upsert**:不存在则插入(`tenantId=请求头tenantId``status=1` 上架,`dataSource=PUSH``saleCount=0`);已存在则更新推送到字段。
- 兼容性:`tenderOnsaleTime`/`tenderOffsaleTime` 支持 `yyyy-MM-dd HH:mm:ss``yyyyMMddHHmmss`、ISO 带 `T` 三种格式解析;解析失败置空。
- 其余标书项目字段(分类、上下架、人工维护数据)仍可在管理后台手工调整。
---
## 3. 出向接口:官方网 → 一站式(推送购买记录/订单)
### 3.1 接口地址
```
POST {一站式BaseUrl}/api/biz/createPurchaseDetails
```
### 3.2 触发时机
- **支付成功后**(官方网 `PUT /api/hjc/order/mark-paid` 置已付)自动推送,请求状态 `status=PAID`
- **退款时**(官方网 `POST /api/hjc/order/refund` 置已退款)推送,请求状态 `status=REFUNDED`**必须**携带 `refundTime`(退款时间)与 `refundReason`(退款原因)。
- `refundTime`:官方网记录退款动作发生时间(服务器时间),落库 `hjc_order.refund_time`
- `refundReason`:后台退款弹窗填写(≤200 字),落库 `hjc_order.refund_reason`;未填写时取默认「管理员操作退款」。
- 推送失败进重试队列后,重试报文的退款时间/原因按订单落库值重建,不会丢失。
### 3.3 请求头
| Header | 值 |
|---|---|
| `appKey` | `HJC_Official_Website` |
| `timestamp` | `yyyyMMddHHmm` |
| `sign` | `MD5(appKey+password+timestamp)` |
| `Content-Type` | `application/json` |
> `password` 不进请求头;`sign` 每次发送须按新的 `timestamp` 重新计算。
### 3.4 请求体字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `idempotencyKey` | string | 幂等键,`HJC_ORD_<orderNo>` |
| `orderNo` | string | 官方网订单号(一站式去重键) |
| `projectNo` | string | 项目编号 |
| `projectName` | string | 项目名称 |
| `tenderPrice` | number | 标书单价(信息服务费) |
| `quantity` | number | 购买数量 |
| `totalAmount` | number | 订单总额 |
| `buyer.enterpriseName` | string | 购买企业名称 |
| `buyer.creditCode` | string | 企业统一社会信用代码 |
| `buyer.contactName` | string | 经办人姓名 |
| `buyer.contactPhone` | string | 经办人手机号 |
| `buyer.contactEmail` | string | 经办人邮箱 |
| `paidAt` | string | 支付时间 `yyyy-MM-dd HH:mm:ss` |
| `payMethod` | string | `WECHAT_NATIVE` / `ALIPAY` |
| `status` | string | `PAID`(已支付)/ `REFUNDED`(已退款) |
| `refundTime` | string | **退款时必传**,退款时间 `yyyy-MM-dd HH:mm:ss``status=REFUNDED` 时推送,其余状态不出现在报文中) |
| `refundReason` | string | **退款时必传**,退款原因(≤255 字符;后台未填写时取默认「管理员操作退款」) |
| `invoiceStatus` | string | `NONE` / `APPLIED` / `ISSUED` |
请求体示例:
```json
{
"idempotencyKey": "HJC_ORD_HJC2026080101ABCD",
"orderNo": "HJC2026080101ABCD",
"projectNo": "ZB-2026-0001",
"projectName": "xxx采购项目",
"tenderPrice": 500.00,
"quantity": 1,
"totalAmount": 500.00,
"buyer": {
"enterpriseName": "某某科技有限公司",
"creditCode": "91110XXXX",
"contactName": "张三",
"contactPhone": "13800000000",
"contactEmail": "a@b.com"
},
"paidAt": "2026-08-01 10:30:00",
"payMethod": "WECHAT_NATIVE",
"status": "PAID",
"invoiceStatus": "NONE"
}
```
退款推送请求体示例(`status=REFUNDED`,多出 `refundTime`/`refundReason`):
```json
{
"idempotencyKey": "HJC_ORD_HJC2026080101ABCD",
"orderNo": "HJC2026080101ABCD",
"projectNo": "ZB-2026-0001",
"projectName": "xxx采购项目",
"tenderPrice": 500.00,
"quantity": 1,
"totalAmount": 500.00,
"buyer": {
"enterpriseName": "某某科技有限公司",
"creditCode": "91110XXXX",
"contactName": "张三",
"contactPhone": "13800000000",
"contactEmail": "a@b.com"
},
"paidAt": "2026-08-01 10:30:00",
"payMethod": "WECHAT_NATIVE",
"status": "REFUNDED",
"refundTime": "2026-08-03 09:15:00",
"refundReason": "项目终止,客户申请退款",
"invoiceStatus": "NONE"
}
```
### 3.5 响应约定
- 一站式按自身 `API` 返回;官方网侧把 `HTTP 2xx` 视为成功,否则记入推送日志(`hjc_order_push_log`)待重试。
- **幂等**:建议一站式以 `orderNo`/`idempotencyKey` 去重,同单重复提交返回成功、不重复插入。
### 3.6 重试策略(官方网侧)
- 失败(非 2xx / 网络异常)写入 `push_status=2``next_retry_time = now + 30min``attempt_count + 1`
- 由定时任务 `retryPendingPush()` 扫描待推送/失败记录重发;重发时 `timestamp/sign` 重新计算。
---
## 4. 字段映射总表
| 一站式字段 | 含义 | 官方网内部字段 |
|---|---|---|
| `projectNo` | 项目编号 | `hjc_bid_project.project_no` |
| `projectName` | 项目名称 | `project_name` |
| `tenderPrice` | 标书价格 | `tender_price` |
| `files` | 标书附件 | `tender_file` |
| `tenderOnsaleTime` | 开售时间 | `onsale_time` |
| `tenderOffsaleTime` | 停售时间 | `offsale_time` |
| `bulletinName` | 公告标题 | `bulletin_title` |
| `customerName` | 招标人 | `tenderer` |
| `supplierName` | 中标公司 | `winner_supplier` |
| `bidAmount` | 中标金额 | `bid_amount` |
| `content` | 公告正文 | `bulletin_content` |
| `fileList` | 公告附件 | `bulletin_file_list` |
| `needSellTender` | 是否卖标书 | `need_sell` |
| `sellingMethod` | 售卖方式 | `selling_method` |
| `refundTime` | 退款时间(出向) | `hjc_order.refund_time` |
| `refundReason` | 退款原因(出向) | `hjc_order.refund_reason` |
---
## 5. 配置清单(待双方/部署方填写)
| 项 | 值 | 位置 |
|---|---|---|
| 官方网 base-url | `http://<官方网域名>/api` | 一站式侧调用方配置 |
| 一站式 base-url | `http://<一站式域名>` | `mp-java/application.yml``hjc.one-stop.base-url` |
| createPurchaseDetails 路径 | `/api/biz/createPurchaseDetails` | `hjc.one-stop.create-purchase-details-path` |
| `appKey` | `HJC_Official_Website` | 双方约定 |
| `password` | `vQ8$kR3#mW6@xP2!nF` | 双方约定 |
| 租户 ID | `10626` | 官方网请求头 `tenantId` |
---
## 6. 待确认事项
1. 出向 `sign` 拼接顺序(`appKey+password+timestamp`)与一站式既有算法是否一致;不一致请给出一站式现网签名规范。
2. `timestamp` 格式、时区、±10min 窗口是否认可。
3. `createPurchaseDetails` 字段名是否需与一站式现网入参完全一致(现按本表定义);如需调整字段名请给出一站式入参清单。
4. 入向:一站式调用官方网 `/api/hjc/push/project` 时是否也按同一套 `appKey/timestamp/sign` 鉴权(本表默认对称鉴权)。
5. 退款字段名 `refundTime` / `refundReason` 与一站式现网入参是否一致;格式默认 `yyyy-MM-dd HH:mm:ss`,如一站式要求其他命名或时间格式请同步调整 `CreatePurchaseDetails`
@@ -0,0 +1,130 @@
# 汇吉采 → 一站式代理平台:标书订单推送接口鉴权规范
> 用途:本文档是**本平台(汇吉采标书购买平台,下称「官方网」)调用一站式代理平台接口**时的鉴权规范,用于对接一站式平台开发人员,请对方按此规则在服务端做校验。
> 目标接口:`POST /api/biz/createPurchaseDetails`(本平台推送购买记录与订单信息到一站式平台落盘)
---
## 1. 共享凭证(双方提前约定,不通过网络传输)
| 参数 | 值 | 说明 |
|---|---|---|
| `appKey` | `HJC_Official_Website` | 调用方标识,明文放在请求头 |
| `password` | `vQ8$kR3#mW6@xP2!nF` | **签名密钥(Secret**,仅双方服务端持有,**不放在请求头、不出现于任何请求/日志/报文** |
> ⚠️ 出于安全考虑,本规范**不把 `password` 明文放请求头**。原始需求中「把 password 明文字段加到请求头」的做法会泄露密钥,改为「用 password 参与签名、请求头只带签名结果」的等价且更安全的方案(见第 3 节)。如需严格沿用原始「明文 password 头」方案,见附录 A。
---
## 2. 请求头(请求方——官方网——每次调用携带 3 个)
| Header | 示例值 | 说明 |
|---|---|---|
| `appKey` | `HJC_Official_Website` | 明文 appKey(固定值) |
| `timestamp` | `202604080103` | 签名时间,格式 `yyyyMMddHHmm`(24 小时制,北京时区,精确到分钟) |
| `sign` | `9f7c…(32 位小写十六进制)` | 签名值,算法见第 3 节 |
另需:`Content-Type: application/json`
---
## 3. 签名算法
```
sign = MD5( appKey + password + timestamp )
```
- `+` 表示**字符串直接拼接**,中间无分隔符。
- `timestamp` 使用与请求头 `timestamp` **完全相同**的值。
- `MD5` 输出**小写** 32 位十六进制字符串。
步骤:
1. 取当前北京时间的分钟表示 `timestamp = yyyyMMddHHmm`(例:`2026-04-08 01:03``202604080103`)。
2. 拼串:`HJC_Official_Website` + `vQ8$kR3#mW6@xP2!nF` + `202604080103`
3. 对该字符串做 MD5,转小写,得到 `sign`
4.`appKey``timestamp``sign` 放入请求头发出。
---
## 4. 校验规则(一站式平台服务端)
收请求后按以下顺序校验,任一步不通过即拒绝(建议返回 `401` / `400`,并附错误码与说明,便于排查):
1. **校验 appKey**`appKey` 是否等于已登记的应用标识 `HJC_Official_Website`。不等 → 拒绝。
2. **校验 timestamp 时效(防重放)**`timestamp` 与**一站式服务器当前时间**之差是否在 **±10 分钟**以内(统一按北京时区、分钟精度)。超出窗口 → 拒绝,提示「时间戳失效」。
- 该窗口同时限制了重放窗口;如需更严可协商收紧到 ±3 分钟。
3. **校验签名**:用共享的 `password` 重算 `MD5(appKey + password + timestamp)`,与请求头 `sign` **不区分大小写**比较(统一小写后比较)。不一致 → 拒绝,提示「签名校验失败」。
4. 校验通过后,按请求体 `createPurchaseDetails` 的字段约定解析并落盘(业务字段见单独一节的「createPurchaseDetails 请求体字段表」)。
---
## 5. 幂等与重试约定(可选但强烈建议)
`createPurchaseDetails` 是「落盘」类写操作,本平台侧会做失败重试,因此建议一站式平台按业务主键幂等处理,避免重复落盘:
- 请求体需带唯一业务单号(如 `purchaseId` / `orderNo`),一站式平台以此作为去重键;**同一单号重复提交时应返回成功、不重复插入**(或返回「已存在」)。
- 本平台对失败的推送会按 `attempt` 递增重试(建议:1/2/4/8 分钟间隔),重试时 `timestamp/sign` 需要**重新计算**(每次请求都要以新时间重签)。
---
## 6. 双端示例
**官方网(请求方)**——以 Python 为例:
```python
import hashlib, time, requests
app_key = "HJC_Official_Website"
password = "vQ8$kR3#mW6@xP2!nF"
ts = time.strftime("%Y%m%d%H%M") # 202604080103
sign = hashlib.md5((app_key + password + ts).encode("utf-8")).hexdigest()
resp = requests.post(
"https://<一站式域名>/api/biz/createPurchaseDetails",
json={ ... 业务字段 ... },
headers={
"appKey": app_key,
"timestamp": ts,
"sign": sign,
"Content-Type": "application/json",
},
timeout=15,
)
```
**一站式(校验方)**——以 Java 为例:
```java
String appKey = req.getHeader("appKey");
String ts = req.getHeader("timestamp");
String sign = req.getHeader("sign");
if (!"HJC_Official_Website".equals(appKey)) return 401; // 1
if (Math.abs(System.currentTimeMillis() / 60000 - parseMinute(ts)) > 10)
return 401; // 2
String expect = md5("HJC_Official_Website" + SECRET + ts); // 3
if (!expect.equalsIgnoreCase(sign)) return 401;
// 4. 解析 body,按 createPurchaseDetails 字段落盘(按 orderNo 幂等)
```
---
## 附录 A:若一站式平台坚持「明文 password 头」的原始方案
原始需求表述为「请求头添加 appKey、passwordappKey+年月日时分、password+年月日时分,MD5 加密」。若对方已按此固定算法实现,则采用:
- 请求头:`appKey=HJC_Official_Website``password=vQ8$kR3#mW6@xP2!nF``sign=MD5(password + yyyyMMddHHmm)`(小写)。
- 校验:`password` 需与约定值一致,且 `MD5(password + timestamp) == sign`,同时 `timestamp` 在 ±10 分钟内。
> 该方案会把 `password` 以明文形式暴露在报文中,安全性差;**仅作兼容退路,不作为默认推荐**。若对方无法确认固定算法,请首选第 1–6 节的方案。
---
## 附:需要一站式平台确认/提供的信息
1. 上述 `sign` 拼接顺序是否与合作方既有算法一致(`appKey+password+timestamp`)。
2. 时间戳格式 `yyyyMMddHHmm` 与北京时区是否认可。
3. 校验窗口 ±10 分钟是否可接受。
4. `createPurchaseDetails` 请求体字段表(本平台另一份文档,见需求/接口清单)的具体入参名与必填项,需对方给出或双方对齐,以免字段名不一致导致落盘失败。
+2
View File
@@ -213,6 +213,8 @@
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.4.3</version>
</dependency>
<!-- 证件 OCR(身份证/营业执照识别)已改用百度云 REST 接口,不再依赖 aliyun-java-sdk-ocr
理由见 ADR-0003《证件 OCR 改用百度云》,调用封装在 com.gxwebsoft.hjc.ocr.HjcBaiduOcrClient -->
<!--阿里支付 老版本 SDK-->
<dependency>
<groupId>com.alipay.sdk</groupId>
+546
View File
@@ -0,0 +1,546 @@
const fs = require('fs');
const path = require('path');
const projectRoot = path.resolve(__dirname, '..');
const sqlDirectory = path.join(projectRoot, 'src', 'main', 'resources', 'sql');
const dataPath = path.join(sqlDirectory, '南宁房产知识库结构化表格.json');
const seedPath = path.join(sqlDirectory, 'house_knowledge_seed.sql');
const startMarker = '-- NANNING_STRUCTURED_DATA_START';
const endMarker = '-- NANNING_STRUCTURED_DATA_END';
const sourceNote = '本地业务资料《南宁房产知识库结构化表格.json》导入,待后台复核';
const data = JSON.parse(fs.readFileSync(dataPath, 'utf8'));
const offices = data['南宁写字楼信息表'] || [];
const communities = data['南宁住宅小区信息表'] || [];
const businessDistrictOverrides = {
'五象航洋城': '五象航洋城商圈'
};
const officialLocationEntries = [
{
region: '青秀区', businessDistrict: '青秀万达',
title: '滨湖路站连接青秀万达与南湖公共配套',
content: '南宁轨道交通3号线滨湖路站位于滨湖路与长湖路交汇处,北邻青秀万达、南近南湖公园。B口周边有南湖公园和南宁市卫生健康委员会,C口周边有南宁市第二妇幼保健院、南宁市口腔医院,D口周边有南宁市第五十四中学、埌东小学。',
tags: ['轨道交通', '商业配套', '公园绿地', '公共服务'],
sourceNote: '南宁轨道交通集团有限责任公司《滨湖路站》 http://www.nngdjt.com/html/service1c/line/0313.html'
},
{
region: '青秀区', businessDistrict: '东盟商务区',
title: '万象城站的商业、办公与公园配套',
content: '南宁轨道交通1号线万象城站位于东盟商务区核心区,设置在民族大道与青秀路、景秀路交叉路口地下。运营方列示,A口连接水电大厦等办公区,C口可通向万象城购物消费中心,B口周边有石门森林公园和多个居住小区。',
tags: ['轨道交通', '商业配套', '商务办公', '公园绿地'],
sourceNote: '南宁轨道交通集团有限责任公司《万象城站》 http://www.nngdjt.com/html/service1c/line/0119.html'
},
{
region: '青秀区', businessDistrict: '东盟商务区',
title: '东盟商务区站周边商业、金融与社区卫生服务',
content: '南宁轨道交通1号线东盟商务区站位于民族大道北侧。运营方列示,A、D口靠近凤岭儿童公园,B、C口周边有三祺广场、广西金融广场、东盟财经中心、东盟盛天地等商业和办公配套;D口周边有青秀区南湖凤岭北社区卫生服务中心。',
tags: ['轨道交通', '商业配套', '商务办公', '公园绿地', '公共服务'],
sourceNote: '南宁轨道交通集团有限责任公司《东盟商务区站》 http://www.nngdjt.com/html/service1c/line/0120.html'
},
{
region: '青秀区', businessDistrict: '凤岭北',
title: '凤岭儿童公园的公园绿地与休闲配套',
content: '凤岭儿童公园位于南宁市青秀区凤岭片区北侧核心区,北临月湾路、南面云景路,与凤岭冲沟相邻,占地834亩。政府页面记载,公园建设包括园内道路、基础绿化、园林构筑、广场铺装、公共设施和配套服务设施。',
tags: ['公园绿地', '文化休闲', '运动健身'],
sourceNote: '南宁市人民政府《凤岭儿童公园》 https://www.nanning.gov.cn/zjnn/nnly/t2566232.html'
},
{
region: '青秀区', businessDistrict: '凤岭北',
title: '凤岭站连接凤岭居住区、教育与公园',
content: '南宁轨道交通1号线凤岭站位于凤岭立交以东、民族大道北侧。运营方列示,B1口周边有南宁市第一幼儿园青林分园,B2口周边有南宁市翡翠园学校,C口通向埌东公园;埌东客运站位于车站以东约900米处。',
tags: ['轨道交通', '公共服务', '公园绿地'],
sourceNote: '南宁轨道交通集团有限责任公司《凤岭站》 http://www.nngdjt.com/html/service1c/line/0121.html'
},
{
region: '青秀区', businessDistrict: '埌东客运站片区',
title: '埌东客运站的公路客运与地铁接驳',
content: '埌东客运站位于民族大道东端与仙葫大道交叉处,地面上方为埌东汽车客运站。站点B、C口对应客运站一侧,E口周边为市公交总站,可作为片区公路客运、公交与地铁接驳信息使用。',
tags: ['轨道交通', '公共服务'],
sourceNote: '南宁轨道交通集团有限责任公司《埌东客运站》 http://www.nngdjt.com/html/service1c/line/0122.html'
},
{
region: '青秀区', businessDistrict: '会展中心',
title: '会展中心站与航洋国际城及会展配套',
content: '会展中心站位于民族大道与会展路交叉路口。B、B1口通往南宁国际会展中心,D口无缝连接航洋国际城;官方资料将该站周边规划概括为居住、商业及行政办公用地。',
tags: ['轨道交通', '商业配套', '商务办公', '公共服务'],
sourceNote: '南宁轨道交通集团有限责任公司《会展中心站》 http://www.nngdjt.com/html/service1c/line/0118.html'
},
{
region: '青秀区', businessDistrict: '南湖片区',
title: '南湖站周边绿地、政务与金融办公配套',
content: '南湖站位于南湖大桥东端、南湖公园绿地内。A口周边有南宁市人民政府,B口通往南湖公园,C口周边列有自治区税务局、自治区发展和改革委员会及多家银行。',
tags: ['轨道交通', '公园绿地', '商务办公', '公共服务'],
sourceNote: '南宁轨道交通集团有限责任公司《南湖站》 http://www.nngdjt.com/html/service1c/line/0116.html'
},
{
region: '青秀区', businessDistrict: '金湖片区',
title: '金湖广场站的双线换乘与民歌湖配套',
content: '金湖广场站位于金湖东环路与民族大道交叉口,是1号线与3号线换乘站。站点周边包括金湖广场、民歌湖、南宁书城、购物中心、金融机构、南宁市政务服务中心及南宁十四中埌东校区。',
tags: ['轨道交通', '商业配套', '文化休闲', '商务办公', '公共服务'],
sourceNote: '南宁轨道交通集团有限责任公司《金湖广场站》 http://www.nngdjt.com/html/service1c/line/0314.html'
},
{
region: '青秀区', businessDistrict: '凤岭南-青秀山片区',
title: '青秀山站与青秀山西门、青秀湖公园',
content: '青秀山站设置4个出入口,B口位于青秀山风景区西门及青秀山管委会一侧;A1口周边包括青秀湖公园。A3口设置无障碍电梯,周边有多条公交线路。',
tags: ['轨道交通', '公园绿地', '文化休闲', '公共服务'],
sourceNote: '南宁轨道交通集团有限责任公司《青秀山站》 http://www.nngdjt.com/html/service1c/line/0317.html'
},
{
region: '良庆区', businessDistrict: '市博物馆片区',
title: '市博物馆站与文化艺术中心配套',
content: '市博物馆站位于博艺路与彩凤路交叉路口,A口周边为广西文化艺术中心,D口周边为南宁市博物馆、金龙路小学及良庆区金龙幼儿园。',
tags: ['轨道交通', '文化休闲', '公共服务'],
sourceNote: '南宁轨道交通集团有限责任公司《市博物馆》 http://www.nngdjt.com/html/service1c/line/0318.html'
},
{
region: '江南区', businessDistrict: '江南万达',
title: '亭洪路沿线的商业、文化与公园节点',
content: '南宁市公开报道将亭洪路描述为江南区城市商贸业和文化旅游的主轴线,并列示江南万达商圈、百益·上河城、华润江南中心、江南公园、广西海吉星、火车南站和国际铁路港等节点分布在亭洪路两侧及周边。报道同时记载,亭洪路延长线建成后将江南万达商圈与沙井商圈连接起来。',
tags: ['商业配套', '文化休闲', '公园绿地', '商务办公'],
sourceNote: '南宁市人民政府《江南区发达路网赋能产城高度融合发展》 https://www.nanning.gov.cn/ywzx/xqdt/2022xqdt/t5048033.html'
},
{
region: '江南区', businessDistrict: '江南公园片区',
title: '江南公园的生态与文化休闲节点',
content: '南宁市江南公园位于壮锦大道17号、紧邻江南区政府,西邻壮锦大道、东接金华路、北靠亭洪路延长线及湘桂铁路线。政府页面记载,公园总面积798亩,于2018年6月正式对外开放,以休闲、生态、文化为主旨,是市级综合性城市公园。',
tags: ['公园绿地', '文化休闲', '公共服务'],
sourceNote: '南宁市文化广电和旅游局《南宁市江南公园》 https://www.nanning.gov.cn/zjnn/nnly/t4137277.html'
},
{
region: '江南区', businessDistrict: '亭子码头片区',
title: '邕江亭子码头滨水公共空间',
content: '南宁市政府公开报道记载,邕江亭子码头于2019年1月正式对市民开放,码头岸线长约500米;报道将其定位为面向邕江观景的滨水码头节点。',
tags: ['文化休闲', '公共服务'],
sourceNote: '南宁市人民政府《邕江亭子码头正式开放》 https://www.nanning.gov.cn/ywzx/nnyw/2019nzwdt/t1593403.html'
},
{
region: '西乡塘区', businessDistrict: '西乡塘大学东路',
title: '广西大学站的高校、社区与公共服务节点',
content: '南宁轨道交通1号线广西大学站位于大学东路与明秀西路交汇处下方。运营方列示,B1口周边有城市碧园、时代天骄和南宁高级技工学校,C口周边有南宁市第二小学,D口周边有西乡塘街道办事处和五里亭第一小学,F口周边有广西大学南门。',
tags: ['轨道交通', '公共服务'],
sourceNote: '南宁轨道交通集团有限责任公司《广西大学站》 http://www.nngdjt.com/html/service1c/line/0109.html'
},
{
region: '西乡塘区', businessDistrict: '西乡塘大学东路',
title: '鲁班路站的学校、医疗与公园节点',
content: '南宁轨道交通1号线鲁班路站位于大学东路与鲁班路交汇处下方。运营方列示,A口周边有明月湖公园、南宁市第二十中学和南宁市公安局西乡塘分局,B口周边有广西大学。',
tags: ['轨道交通', '公园绿地', '公共服务'],
sourceNote: '南宁轨道交通集团有限责任公司《鲁班路站》 http://www.nngdjt.com/html/service1c/line/0108.html'
},
{
region: '西乡塘区', businessDistrict: '相思湖片区',
title: '民族大学站的高校与基层医疗节点',
content: '南宁轨道交通1号线民族大学站位于大学东路与新村大道交叉路口,沿大学东路设置。运营方列示,车站西北侧有西乡塘卫生院,东北侧有广西民族大学和相思湖国际大酒店,西南侧有广西交通运输学校、广西外国语学校,东南侧有广西机电职业技术学院、广西社会主义学院和西乡塘派出所。',
tags: ['轨道交通', '公共服务'],
sourceNote: '南宁轨道交通集团有限责任公司《民族大学站》 http://www.nngdjt.com/html/service1c/line/0105.html'
},
{
region: '西乡塘区', businessDistrict: '南宁动物园片区',
title: '动物园站的亲子科普主题公共空间',
content: '南宁市2026年公开报道记载,南宁地铁1号线动物园站毗邻南宁动物园,并由南宁市动物园与南宁地铁围绕动物文化、科普教育和儿童友好理念打造主题车站;站内设置动物IP科普墙、动物主题儿童休息区和小象主题母婴室等场景。',
tags: ['轨道交通', '文化休闲', '公共服务'],
sourceNote: '南宁市人民政府《IP内容+空间资源 南宁地铁1号线动物园站化身童趣乐园》 https://www.nanning.gov.cn/ywzx/tpxw/t6647075.html'
},
{
region: '兴宁区', businessDistrict: '兴宁老城区',
title: '三街两巷历史文化街区',
content: '南宁市三街两巷历史街区是南宁主城区规模最大的历史文化街区,由解放路、兴宁路、民生路三条老街和金狮巷、银狮巷两条古巷组成,汇聚文物保护单位及历史建筑,是近代南宁较集中的骑楼建筑片区。',
tags: ['文化休闲', '商业配套'],
sourceNote: '南宁市人民政府《历史文化街区“三街两巷”露芳容》 https://www.nanning.gov.cn/zjnn/mswh/t1526950.html'
},
{
region: '邕宁区', businessDistrict: '龙岗',
title: '轨道交通4号线后通段接入龙岗',
content: '南宁市公开报道记载,2025年9月轨道交通4号线一期工程后通段(楞塘村站至龙岗站)开通运营,龙岗站成为该后通段终点站,邕宁区由此接入轨道交通网络。',
tags: ['轨道交通'],
sourceNote: '邕宁区人民政府《喜大普奔!地铁4号线后通段开通运营,邕宁迈入“地铁时代”!》 https://www.nanning.gov.cn/ywzx/xqdt/2025nxqdt/t6428425.html'
},
{
region: '邕宁区', businessDistrict: '园博园片区',
title: '南宁园博园的园林与文化展示节点',
content: '南宁园博园是第十二届中国国际园林博览会举办地,位于邕宁区八尺江畔顶蛳山地块,占地276公顷,2018年12月建成开放。政府页面记载,园内有新石器时代文化遗址、丘陵与原生山林、湖塘和湿地,并设中华城市展园、东盟园、丝路园、广西园、设计师园和企业园等主题展园区。',
tags: ['公园绿地', '文化休闲'],
sourceNote: '南宁市人民政府《南宁园博园》 https://www.nanning.gov.cn/zjnn/nnly/t2566279.html'
},
{
region: '青秀区', businessDistrict: '埌西片区',
title: '埌西站周边教育、人社与生活服务',
content: '埌西站位于金湖路与桂春路交叉路口。站点周边列有南宁市教育局、南宁市人力资源和社会保障局、南宁市民政局、广西疾病预防控制中心、埌西综合市场、人才市场及多所学校。',
tags: ['轨道交通', '公共服务', '商业配套'],
sourceNote: '南宁轨道交通集团有限责任公司《埌西站》 http://www.nngdjt.com/html/service1c/line/0315.html'
}
];
if (sourceNote.length > 500 || officialLocationEntries.some((item) => item.sourceNote.length > 500)) {
throw new Error('知识条目的来源说明超过数据库字段长度');
}
function sql(value) {
if (value === null || value === undefined) {
return 'NULL';
}
return `'${String(value).replace(/'/g, "''")}'`;
}
function known(value) {
return value !== undefined && value !== null && String(value).trim() !== ''
&& String(value).trim() !== '待定';
}
function exactNumber(value) {
const text = String(value || '').trim();
return /^\d+(?:\.\d+)?$/.test(text) ? text : null;
}
function businessDistrictName(item) {
return businessDistrictOverrides[item['楼盘名称']] || item['所属商圈'];
}
function text(parts) {
return parts.filter(([_, value]) => known(value))
.map(([label, value]) => `${label}${value}`)
.join('');
}
function locationFilter(item) {
return [
'FROM house_knowledge_location community',
'JOIN house_knowledge_location business_district',
' ON business_district.location_id = community.parent_location_id',
'WHERE community.tenant_id = @house_knowledge_tenant_id',
" AND community.city = '南宁'",
" AND community.location_type = 'community'",
` AND community.location_name = ${sql(item['楼盘名称'])}`,
' AND community.deleted = 0',
' AND business_district.tenant_id = @house_knowledge_tenant_id',
" AND business_district.location_type = 'business_district'",
` AND business_district.location_name = ${sql(businessDistrictName(item))}`,
' AND business_district.deleted = 0'
].join('\n');
}
function tagInsert(topic, item, tags) {
const values = [...tags].filter(Boolean).map(sql).join(', ');
return [
'INSERT IGNORE INTO house_knowledge_entry_tag (entry_id, tag_id, tenant_id)',
'SELECT entry.entry_id, tag.tag_id, @house_knowledge_tenant_id',
'FROM house_knowledge_entry entry',
'JOIN house_knowledge_location community ON community.location_id = entry.location_id',
'JOIN house_knowledge_location business_district',
' ON business_district.location_id = community.parent_location_id',
'JOIN house_knowledge_tag tag ON tag.tenant_id = @house_knowledge_tenant_id',
' AND tag.deleted = 0',
'WHERE entry.tenant_id = @house_knowledge_tenant_id',
' AND entry.deleted = 0',
' AND entry.status = 0',
` AND entry.topic = ${sql(topic)}`,
` AND entry.source_note = ${sql(sourceNote)}`,
" AND community.city = '南宁'",
" AND community.location_type = 'community'",
` AND community.location_name = ${sql(item['楼盘名称'])}`,
` AND business_district.location_name = ${sql(businessDistrictName(item))}`,
` AND tag.tag_name IN (${values});`
].join('\n');
}
function entryInsert(item, topic, title, content, fields) {
return [
'INSERT IGNORE INTO house_knowledge_entry (',
' location_id, topic, title, content, property_company, property_fees,',
' water_billing_type, water_unit_price, electricity_billing_type, electricity_unit_price,',
' parking_available, parking_fee, verified_date, source_note, status, tenant_id, deleted',
')',
'SELECT',
' community.location_id,',
` ${sql(topic)},`,
` ${sql(title)},`,
` ${sql(content || null)},`,
` ${sql(fields.propertyCompany)},`,
` ${fields.propertyFees || 'NULL'},`,
` ${sql(fields.waterBillingType)},`,
` ${fields.waterUnitPrice || 'NULL'},`,
` ${sql(fields.electricityBillingType)},`,
` ${fields.electricityUnitPrice || 'NULL'},`,
` ${fields.parkingAvailable === null ? 'NULL' : fields.parkingAvailable ? '1' : '0'},`,
` ${sql(fields.parkingFee)},`,
' CURDATE(),',
` ${sql(sourceNote)},`,
' 0, @house_knowledge_tenant_id, 0',
locationFilter(item) + ';'
].join('\n');
}
function parkingAvailable(item) {
if (item['地下停车场'] === '有') {
return true;
}
if (item['地下停车场'] === '无') {
return false;
}
return known(item['停车位数量']) ? true : null;
}
function entryBlocks(item, type) {
const name = item['楼盘名称'];
const typeTag = type === 'office' ? '写字楼' : '住宅小区';
const propertyContent = text([
['物业类型', item['物业类型']],
['物业公司', item['物业公司']],
['物业费标准', item['物业费标准(元/㎡/月)']],
['物业服务时间', item['物业服务时间']],
['内部配套', item['内部配套']],
['总层数', item['总层数']],
['建成年份', item['建成年份']],
['产权年限', item['产权年限']],
['备注', item['备注']]
]);
const utilitiesContent = text([
['水电类型', item['水电类型']],
['水费标准', item['水费标准(元/吨)']],
['电费标准', item['电费标准(元/度)']],
['24小时供水供电', item['24小时供水供电']]
]);
const parkingContent = text([
['停车位数量', item['停车位数量']],
['车位配比', item['车位配比']],
['地下停车场', item['地下停车场']],
['充电桩配置', item['充电桩配置']],
['临停收费标准', item['临停收费标准']],
['月保收费标准', item['月保收费标准']]
]);
const otherContent = text([
['所属区域', item['所属区域']],
['详细地址', item['详细地址']],
['所属商圈', businessDistrictName(item)],
['周边配套', item['周边配套']]
]);
const propertyTags = new Set(['物业服务', typeTag]);
const utilitiesTags = new Set(['水电计费', typeTag]);
const parkingTags = new Set(['停车信息', typeTag]);
const otherTags = new Set(['商业配套', typeTag]);
if (item['物业服务时间'] === '24小时') {
propertyTags.add('24小时服务');
}
if (item['充电桩配置'] === '有') {
parkingTags.add('充电设施');
}
if (String(item['周边配套'] || '').includes('地铁')) {
otherTags.add('轨道交通');
otherTags.add('地铁出行');
}
if (type === 'office') {
otherTags.add('商务办公');
}
return [
entryInsert(item, 'property', `${name}的物业服务`, propertyContent, {
propertyCompany: known(item['物业公司']) ? item['物业公司'] : null,
propertyFees: exactNumber(item['物业费标准(元/㎡/月)']),
waterBillingType: null,
waterUnitPrice: null,
electricityBillingType: null,
electricityUnitPrice: null,
parkingAvailable: null,
parkingFee: null
}),
tagInsert('property', item, propertyTags),
entryInsert(item, 'utilities', `${name}的水电计费`, utilitiesContent, {
propertyCompany: null,
propertyFees: null,
waterBillingType: known(item['水电类型']) ? item['水电类型'] : null,
waterUnitPrice: exactNumber(item['水费标准(元/吨)']),
electricityBillingType: known(item['水电类型']) ? item['水电类型'] : null,
electricityUnitPrice: exactNumber(item['电费标准(元/度)']),
parkingAvailable: null,
parkingFee: null
}),
tagInsert('utilities', item, utilitiesTags),
entryInsert(item, 'parking', `${name}的停车与充电`, parkingContent, {
propertyCompany: null,
propertyFees: null,
waterBillingType: null,
waterUnitPrice: null,
electricityBillingType: null,
electricityUnitPrice: null,
parkingAvailable: parkingAvailable(item),
parkingFee: text([
['临停', item['临停收费标准']],
['月保', item['月保收费标准']]
]) || null
}),
tagInsert('parking', item, parkingTags),
entryInsert(item, 'other', `${name}的地段与周边配套`, otherContent, {
propertyCompany: null,
propertyFees: null,
waterBillingType: null,
waterUnitPrice: null,
electricityBillingType: null,
electricityUnitPrice: null,
parkingAvailable: null,
parkingFee: null
}),
tagInsert('other', item, otherTags)
];
}
function officialLocationInsert(item) {
return [
'INSERT IGNORE INTO house_knowledge_location (',
' city, location_type, location_name, parent_location_id, status, tenant_id, deleted',
')',
'SELECT',
` '南宁', 'business_district', ${sql(item.businessDistrict)}, region.location_id,`,
' 0, @house_knowledge_tenant_id, 0',
'FROM house_knowledge_location region',
'WHERE region.tenant_id = @house_knowledge_tenant_id',
" AND region.city = '南宁'",
" AND region.location_type = 'region'",
` AND region.location_name = ${sql(item.region)}`,
' AND region.deleted = 0',
' AND NOT EXISTS (',
' SELECT 1 FROM house_knowledge_location existing',
' WHERE existing.tenant_id = @house_knowledge_tenant_id',
" AND existing.city = '南宁'",
" AND existing.location_type = 'business_district'",
` AND existing.location_name = ${sql(item.businessDistrict)}`,
' AND existing.deleted = 0',
' );'
].join('\n');
}
function officialEntryInsert(item) {
return [
'INSERT IGNORE INTO house_knowledge_entry (',
' location_id, topic, title, content, verified_date, source_note,',
' status, tenant_id, deleted',
')',
'SELECT',
' business_district.location_id,',
" 'other',",
` ${sql(item.title)},`,
` ${sql(item.content)},`,
" '2026-08-02',",
` ${sql(item.sourceNote)},`,
' 0, @house_knowledge_tenant_id, 0',
'FROM house_knowledge_location business_district',
'WHERE business_district.tenant_id = @house_knowledge_tenant_id',
" AND business_district.city = '南宁'",
" AND business_district.location_type = 'business_district'",
` AND business_district.location_name = ${sql(item.businessDistrict)}`,
' AND business_district.deleted = 0;'
].join('\n');
}
function officialTagInsert(item) {
return [
'INSERT IGNORE INTO house_knowledge_entry_tag (entry_id, tag_id, tenant_id)',
'SELECT entry.entry_id, tag.tag_id, @house_knowledge_tenant_id',
'FROM house_knowledge_entry entry',
'JOIN house_knowledge_location business_district ON business_district.location_id = entry.location_id',
'JOIN house_knowledge_tag tag ON tag.tenant_id = @house_knowledge_tenant_id',
' AND tag.deleted = 0',
'WHERE entry.tenant_id = @house_knowledge_tenant_id',
' AND entry.deleted = 0',
' AND entry.status = 0',
" AND entry.topic = 'other'",
` AND entry.source_note = ${sql(item.sourceNote)}`,
" AND business_district.city = '南宁'",
" AND business_district.location_type = 'business_district'",
` AND business_district.location_name = ${sql(item.businessDistrict)}`,
` AND tag.tag_name IN (${item.tags.map(sql).join(', ')});`
].join('\n');
}
const records = [
...offices.map((item) => ({ item, type: 'office' })),
...communities.map((item) => ({ item, type: 'community' }))
];
const regions = [...new Set(records.map(({ item }) => item['所属区域']).filter(known))];
const districts = [...new Map(records
.filter(({ item }) => known(item['所属区域']) && known(businessDistrictName(item)))
.map(({ item }) => [`${item['所属区域']}\u0000${businessDistrictName(item)}`, item])).values()];
const lines = [
'-- 以下数据由南宁房产知识库结构化表格.json 和官方公开资料生成。',
`-- 共 ${offices.length} 个写字楼、${communities.length} 个住宅小区和 ${officialLocationEntries.length} 条官方片区知识;来源说明仅后台可见。`,
''
];
for (const region of regions) {
lines.push([
'INSERT IGNORE INTO house_knowledge_location (',
' city, location_type, location_name, parent_location_id, status, tenant_id, deleted',
') VALUES (',
` '南宁', 'region', ${sql(region)}, 0, 0, @house_knowledge_tenant_id, 0`,
');',
''
].join('\n'));
}
for (const item of districts) {
lines.push([
'INSERT IGNORE INTO house_knowledge_location (',
' city, location_type, location_name, parent_location_id, status, tenant_id, deleted',
')',
'SELECT',
` '南宁', 'business_district', ${sql(businessDistrictName(item))}, region.location_id,`,
' 0, @house_knowledge_tenant_id, 0',
'FROM house_knowledge_location region',
'WHERE region.tenant_id = @house_knowledge_tenant_id',
" AND region.city = '南宁'",
" AND region.location_type = 'region'",
` AND region.location_name = ${sql(item['所属区域'])}`,
' AND region.deleted = 0',
' AND NOT EXISTS (',
' SELECT 1 FROM house_knowledge_location existing',
' WHERE existing.tenant_id = @house_knowledge_tenant_id',
" AND existing.city = '南宁'",
" AND existing.location_type = 'business_district'",
` AND existing.location_name = ${sql(businessDistrictName(item))}`,
' AND existing.deleted = 0',
' );',
''
].join('\n'));
}
for (const item of officialLocationEntries) {
lines.push(officialLocationInsert(item), '');
}
for (const { item } of records) {
lines.push([
'INSERT IGNORE INTO house_knowledge_location (',
' city, location_type, location_name, parent_location_id, status, tenant_id, deleted',
')',
'SELECT',
` '南宁', 'community', ${sql(item['楼盘名称'])}, business_district.location_id,`,
' 0, @house_knowledge_tenant_id, 0',
'FROM house_knowledge_location business_district',
'WHERE business_district.tenant_id = @house_knowledge_tenant_id',
" AND business_district.city = '南宁'",
" AND business_district.location_type = 'business_district'",
` AND business_district.location_name = ${sql(businessDistrictName(item))}`,
' AND business_district.deleted = 0;',
''
].join('\n'));
}
for (const { item, type } of records) {
lines.push(...entryBlocks(item, type), '');
}
for (const item of officialLocationEntries) {
lines.push(officialEntryInsert(item), officialTagInsert(item), '');
}
const seed = fs.readFileSync(seedPath, 'utf8');
const start = seed.indexOf(startMarker);
const end = seed.indexOf(endMarker);
if (start < 0 || end < 0 || end < start) {
throw new Error('未找到南宁结构化数据生成区段标记');
}
const generated = `${startMarker}\n${lines.join('\n')}${endMarker}`;
fs.writeFileSync(seedPath, `${seed.slice(0, start)}${generated}${seed.slice(end + endMarker.length)}`, 'utf8');
console.log(`已生成 ${records.length} 个地点的房源知识种子数据。`);
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
汇吉采 <-- 一站式:推送标书项目测试脚本(入向 POST /api/hjc/push/project
用法:
python hjc_test_push.py # 默认 http://uk.frp.one:62335
python hjc_test_push.py http://uk.frp.one:62335
python hjc_test_push.py --base http://uk.frp.one:62335 --project-no TEST-xxx
无第三方依赖,仅标准库(hashlib + urllib)。时间用北京时间(UTC+8)。
签名 sign = MD5(appKey + password + timestamp)timestamp = yyyyMMddHHmm。
"""
import sys
import json
import hashlib
import urllib.request
import urllib.error
import datetime
APP_KEY = "HJC_Official_Website"
PASSWORD = "vQ8$kR3#mW6@xP2!nF"
DEFAULT_BASE = "http://uk.frp.one:62335"
DEFAULT_PAYLOAD = {
"projectNo": "TEST-HJC-20260801",
"projectName": "测试标书项目·服务器采购",
"category": "货物类",
"tenderPrice": 500.00,
"files": "[{\"name\":\"招标文件.pdf\",\"url\":\"https://file.example.com/t.pdf\"}]",
"tenderOnsaleTime": "2026-08-01 09:00:00",
"tenderOffsaleTime": "2026-08-10 17:00:00",
"bulletinName": "中标公告-测试",
"customerName": "某招标单位",
"supplierName": "某中标公司",
"bidAmount": 128000.00,
"content": "测试公告正文。中标供应商:某中标公司,中标金额 128000 元。",
"fileList": "[{\"name\":\"中标通知书.pdf\",\"url\":\"https://file.example.com/n.pdf\"}]",
"needSellTender": 1,
"sellingMethod": 2
}
def bj_now() -> str:
"""北京时间 yyyyMMddHHmm"""
return (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)).strftime("%Y%m%d%H%M")
def main():
base = DEFAULT_BASE
payload = dict(DEFAULT_PAYLOAD)
args = sys.argv[1:]
i = 0
while i < len(args):
a = args[i]
if a in ("--base", "-b"):
base = args[i + 1]
i += 2
elif a in ("--project-no", "-p"):
payload["projectNo"] = args[i + 1]
i += 2
elif a == "--json":
with open(args[i + 1], encoding="utf-8") as f:
payload = json.load(f)
i += 2
else:
base = a # 位置参数作为 baseUrl
i += 1
url = base.rstrip("/") + "/api/hjc/push/project"
ts = bj_now()
sign = hashlib.md5((APP_KEY + PASSWORD + ts).encode("utf-8")).hexdigest()
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=body, method="POST", headers={
"Content-Type": "application/json; charset=utf-8",
"appKey": APP_KEY,
"timestamp": ts,
"sign": sign,
"tenantId": "10626"
})
print("== 请求 ==")
print("URL:", url)
print("timestamp:", ts, "| sign:", sign)
print("body:", body.decode("utf-8"))
print()
print("== 响应 ==")
try:
with urllib.request.urlopen(req, timeout=20) as resp:
print("HTTP", resp.status)
print(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
print("HTTP", e.code)
print(e.read().decode("utf-8"))
except Exception as e:
print("ERROR:", type(e).__name__, e)
if __name__ == "__main__":
main()
+657
View File
@@ -0,0 +1,657 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
汇吉采 → 一站式:**出向**推送联调探测脚本(POST {base-url}{create-purchase-details-path}
与同目录 hjc_test_push.py(入向:一站式 → 官方网 /api/hjc/push/project)互为反向。
用途:一条命令回答「出向对接到底通没通、卡在哪一层」。
脚本会自动做三件事:
1. 按文档约定的签名方案(sign = MD5(appKey + password + timestamp))发起真实推送;
2. 额外遍历若干鉴权头变体(含 password 明文头等历史解读),用于区分「签名不对」与「根本没校验签名」;
3. 用对照探测把失败定位到具体一层:登录过滤器 / 路由不存在 / 验签失败 / 网络不可达。
默认参数自动从仓库读取,避免与代码脱节:
- application.yml → hjc.one-stop.base-url、create-purchase-details-path
- HjcOneStopAuthUtil.java → APP_KEY、PASSWORD
用法::
python hjc_test_push_out.py # 全量:约定方案 + 变体矩阵 + 对照探测
python hjc_test_push_out.py --quick # 只打约定方案(日常回归用)
python hjc_test_push_out.py --repeat 3 # 约定方案连打 3 次(同一订单号,验幂等)
python hjc_test_push_out.py --base http://x:8810 --path /api/biz/createPurchaseDetails
python hjc_test_push_out.py --order-no HJC2026080101ABCD --json result.json
python hjc_test_push_out.py --dry-run # 只打印将要发出的请求,不真的发
订单号默认是固定值 ``TEST-HJC-ORDER-20260801``(不随时间变化):它同时是一站式的幂等键,
固定住才能重复验证幂等/状态流转,也不会每跑一次就在对方库里堆一条新订单;要换单号显式传 ``--order-no``。
退款报文(status=REFUNDED,必带 refundTime + refundReason,见 docs/一站式平台对接-接口文档.md §3.4)::
python hjc_test_push_out.py --refund --dry-run # 看退款报文长什么样
python hjc_test_push_out.py --refund --quick # 打退款报文
python hjc_test_push_out.py --paid-then-refund # 同一订单号先 PAID 再 REFUNDED(推荐的联调姿势)
python hjc_test_push_out.py --refund --order-no HJC2026080101ABCD \\
--refund-reason "项目终止,客户申请退款" --refund-time "2026-08-03 09:15:00"
无第三方依赖,仅标准库。时间统一按北京时间(UTC+8)。
退出码:0=出向推送成功(收到 2xx);1=失败(并打印定位结论)。
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
try: # Windows 控制台默认 GBK,强制 UTF-8 避免中文乱码
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# --------------------------------------------------------------------------- #
# 默认值 / 配置读取
# --------------------------------------------------------------------------- #
HERE = os.path.dirname(os.path.abspath(__file__))
MP_JAVA = os.path.dirname(HERE)
APPLICATION_YML = os.path.join(MP_JAVA, "src", "main", "resources", "application.yml")
AUTH_UTIL_JAVA = os.path.join(
MP_JAVA, "src", "main", "java", "com", "gxwebsoft", "hjc", "util", "HjcOneStopAuthUtil.java"
)
FALLBACK_BASE = "http://180.141.88.21:8810"
FALLBACK_PATH = "/api/biz/createPurchaseDetails"
FALLBACK_APP_KEY = "HJC_Official_Website"
FALLBACK_PASSWORD = "vQ8$kR3#mW6@xP2!nF"
TS_FMT = "%Y%m%d%H%M"
DT_FMT = "%Y-%m-%d %H:%M:%S"
# argparse 的 help 会对 % 做格式化,展示用的时间格式需转义
DT_FMT_HELP = DT_FMT.replace("%", "%%")
STATUS_PAID = "PAID"
STATUS_REFUNDED = "REFUNDED"
# 固定探测订单号:不随时间变化,重复运行始终命中一站式同一条记录。
# 订单号同时是一站式幂等键(HJC_ORD_<orderNo>),固定值才能稳定验证幂等与状态流转,
# 也不会每跑一次就在对方库里堆一条新订单。(与 hjc_test_push.py 固定 projectNo 同一思路)
DEFAULT_ORDER_NO = "PROBE_202609111127"
# 与 HjcBizServiceImpl.DEFAULT_REFUND_REASON 对齐:退款原因留空时官方网会兜底这个值
DEFAULT_REFUND_REASON = "联调探测:客户申请退款"
# 与 hjc_order.refund_reason 字段长度对齐(varchar(255)
REFUND_REASON_MAX = 255
def _read_text(path: str) -> str:
try:
with open(path, encoding="utf-8") as f:
return f.read()
except OSError:
return ""
def load_config() -> tuple[str, str]:
"""从仓库读取 base-url / path,读不到则回退到内置值。"""
base, path = FALLBACK_BASE, FALLBACK_PATH
yml = _read_text(APPLICATION_YML)
if yml:
m = re.search(r'base-url:\s*"([^"]*)"', yml)
if m and m.group(1).strip():
base = m.group(1).strip()
m = re.search(r"create-purchase-details-path:\s*(\S+)", yml)
if m and m.group(1).strip():
path = m.group(1).strip().strip('"')
return base, path
def load_credentials() -> tuple[str, str]:
"""从 HjcOneStopAuthUtil.java 读取 APP_KEY / PASSWORD,读不到则回退。"""
app_key, password = FALLBACK_APP_KEY, FALLBACK_PASSWORD
java = _read_text(AUTH_UTIL_JAVA)
if java:
m = re.search(r'APP_KEY\s*=\s*"([^"]*)"', java)
if m:
app_key = m.group(1)
m = re.search(r'PASSWORD\s*=\s*"([^"]*)"', java)
if m:
password = m.group(1)
return app_key, password
# --------------------------------------------------------------------------- #
# 签名 / 时间
# --------------------------------------------------------------------------- #
def bj_now() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc).astimezone(dt.timezone(dt.timedelta(hours=8)))
def timestamp_str(now: dt.datetime) -> str:
return now.strftime(TS_FMT)
def sign_of(app_key: str, password: str, ts: str) -> str:
return hashlib.md5((app_key + password + ts).encode("utf-8")).hexdigest()
def md5(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
# --------------------------------------------------------------------------- #
# HTTP
# --------------------------------------------------------------------------- #
def post(url: str, body: bytes, headers: dict, timeout: float, insecure: bool = False):
"""返回 (http_code, response_text, elapsed_ms, error)。http_code=0 表示网络层失败。"""
req = urllib.request.Request(url, data=body, method="POST", headers=headers)
ctx = ssl._create_unverified_context() if insecure else None
t0 = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
text = resp.read().decode("utf-8", errors="replace")
return resp.status, text, (time.monotonic() - t0) * 1000, None
except urllib.error.HTTPError as e:
text = e.read().decode("utf-8", errors="replace")
return e.code, text, (time.monotonic() - t0) * 1000, None
except Exception as e: # noqa: BLE001 - 网络层任何异常都归为不可达
return 0, "", (time.monotonic() - t0) * 1000, f"{type(e).__name__}: {e}"
# --------------------------------------------------------------------------- #
# 响应判定
# --------------------------------------------------------------------------- #
LOGIN_HINTS = ("token失效", "请重新登录", "未登录", "not login", "unauthorized")
SIGN_HINTS = ("签名", "验签", "sign校验", "invalid signature", "signature")
TS_HINTS = ("时间戳", "timestamp失效", "timestamp expired")
VERDICT_LABEL = {
"OK": "✅ 通过",
"LOGIN_FILTER": "🔒 登录过滤器拦截",
"SIGN_REJECT": "❌ 验签被拒",
"TS_REJECT": "❌ 时间戳被拒",
"NOT_FOUND": "🚫 路由不存在(404)",
"UNREACHABLE": "🌐 网络不可达",
"OTHER": "❓ 其他",
}
def classify(status: int, body: str) -> str:
if status == 0:
return "UNREACHABLE"
if 200 <= status < 300:
return "OK"
low = (body or "").lower()
if any(h in low for h in TS_HINTS):
return "TS_REJECT"
if any(h in low for h in SIGN_HINTS):
return "SIGN_REJECT"
if any(h in low for h in LOGIN_HINTS):
return "LOGIN_FILTER"
if status == 404:
return "NOT_FOUND"
return "OTHER"
def brief(body: str, limit: int = 160) -> str:
one = " ".join((body or "").split())
return one if len(one) <= limit else one[:limit] + ""
def fingerprint(status: int, body: str) -> str:
"""响应的「错误语义指纹」:忽略每次都变的 timestamp/path 字段,只保留结论性字段。
用于判断两次响应是否表达同一个错误(例如真实接口与不存在路径都被同一个过滤器拒绝)。
"""
try:
obj = json.loads(body)
if isinstance(obj, dict):
keep = {k: obj[k] for k in ("status", "error", "message") if k in obj}
if keep:
return json.dumps(keep, ensure_ascii=False, sort_keys=True)
except (ValueError, TypeError):
pass
return " ".join((body or "").split())[:200] or f"HTTP {status}"
# --------------------------------------------------------------------------- #
# 报文
# --------------------------------------------------------------------------- #
def default_payload(
order_no: str,
now: dt.datetime,
status: str = STATUS_PAID,
refund_reason: str | None = None,
refund_time: dt.datetime | None = None,
) -> dict:
"""构造 createPurchaseDetails 请求体。
status=REFUNDED 时追加 refundTime / refundReason(官方网侧同样只在退款单推这两个字段,
见 HjcBizServiceImpl#buildCreatePurchaseDetails)。
"""
payload = {
"idempotencyKey": "HJC_ORD_" + order_no,
"orderNo": order_no,
"projectNo": "HJC-GXZCY2-2026002",
"projectName": "出向推送联调探测",
"tenderPrice": 0.01,
"quantity": 1,
"totalAmount": 0.01,
"buyer": {
"enterpriseName": "联调探测企业",
"creditCode": "91450000PROBE01",
"contactName": "联调",
"contactPhone": "13800000000",
"contactEmail": "probe@example.com",
},
"paidAt": now.strftime(DT_FMT),
"payMethod": "WECHAT_NATIVE",
"status": status,
"invoiceStatus": "NONE",
}
if status == STATUS_REFUNDED:
reason = (refund_reason or "").strip() or DEFAULT_REFUND_REASON
payload["refundTime"] = (refund_time or now).strftime(DT_FMT)
payload["refundReason"] = reason[:REFUND_REASON_MAX]
return payload
def refund_payload_issues(payload: dict) -> list[str]:
"""退款报文的本地自检:官方网/一站式都要求这两个字段非空,先在这里拦住低级错误。"""
issues: list[str] = []
if payload.get("status") != STATUS_REFUNDED:
return issues
for field in ("refundTime", "refundReason"):
value = payload.get(field)
if not isinstance(value, str) or not value.strip():
issues.append(f"{field} 缺失或为空(退款推送必带)")
rt = payload.get("refundTime")
if isinstance(rt, str) and rt.strip():
try:
dt.datetime.strptime(rt.strip(), DT_FMT)
except ValueError:
issues.append(f"refundTime 格式应为 {DT_FMT}(当前: {rt}")
reason = payload.get("refundReason")
if isinstance(reason, str) and len(reason) > REFUND_REASON_MAX:
issues.append(f"refundReason 超过 {REFUND_REASON_MAX} 字符(当前: {len(reason)}")
return issues
def case_matrix(app_key: str, password: str, ts: str, sign: str) -> list[tuple[str, dict, str]]:
"""返回 [(方案名, 请求头, 说明)],方案 1 为文档约定方案。"""
return [
(
"1 文档约定方案",
{"appKey": app_key, "timestamp": ts, "sign": sign},
"appKey+timestamp+signsign=MD5(appKey+password+timestamp)",
),
(
"2 约定 + password 明文头",
{"appKey": app_key, "timestamp": ts, "sign": sign, "password": password},
"在原需求「明文 password 头」与现规范之间的折中",
),
(
"3 仅 password 明文头",
{"password": password},
"只带密钥,不带 appKey/sign",
),
(
"4 appKey + password 明文",
{"appKey": app_key, "password": password, "timestamp": ts},
"按原文需求字面实现(无 sign",
),
(
"5 双 MD5 解读",
{"appKey": md5(app_key + ts), "password": md5(password + ts), "timestamp": ts},
"原文「appKey+年月日时分,password+年月日时分,MD5加密」的另一种读法",
),
(
"6 Authorization 头",
{"appKey": app_key, "timestamp": ts, "Authorization": sign},
"把签名放 Authorization,适配标准网关",
),
(
"7 无任何鉴权头",
{},
"基准:对比有无鉴权头的响应差异",
),
]
# --------------------------------------------------------------------------- #
# 主流程
# --------------------------------------------------------------------------- #
def main() -> int:
ap = argparse.ArgumentParser(
description="汇吉采 → 一站式 出向推送联调探测",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
cfg_base, cfg_path = load_config()
cfg_key, cfg_pwd = load_credentials()
ap.add_argument("--base", default=cfg_base, help=f"一站式 base-url(默认读配置: {cfg_base}")
ap.add_argument("--path", default=cfg_path, help=f"推送路径(默认读配置: {cfg_path}")
ap.add_argument("--appkey", default=cfg_key, help="appKey")
ap.add_argument("--password", default=cfg_pwd, help="签名密钥 password")
ap.add_argument("--order-no", default=DEFAULT_ORDER_NO, help=f"订单号(默认固定值 {DEFAULT_ORDER_NO}")
ap.add_argument("--payload", default=None, help="自定义请求体 JSON 文件")
ap.add_argument(
"--refund",
action="store_true",
help="推退款报文(status=REFUNDED,带 refundTime/refundReason",
)
ap.add_argument(
"--paid-then-refund",
action="store_true",
help="同一订单号先推 PAID 基线、再推 REFUNDED(模拟真实退款状态流转)",
)
ap.add_argument(
"--refund-reason",
default=None,
help=f"退款原因(默认「{DEFAULT_REFUND_REASON}」,最长 {REFUND_REASON_MAX} 字符)",
)
ap.add_argument(
"--refund-time",
default=None,
help=f"退款时间,格式 {DT_FMT_HELP}(默认当前北京时间)",
)
ap.add_argument("--repeat", type=int, default=1, help="约定方案重复次数,用于验幂等(默认 1)")
ap.add_argument("--timeout", type=float, default=15.0, help="单请求超时秒数(默认 15")
ap.add_argument("--quick", action="store_true", help="只跑约定方案,跳过变体矩阵与对照探测")
ap.add_argument("--no-control", action="store_true", help="跳过对照探测(不额外打扰对方服务)")
ap.add_argument("--dry-run", action="store_true", help="只打印请求,不发送")
ap.add_argument("--insecure", action="store_true", help="跳过 HTTPS 证书校验")
ap.add_argument("--json", dest="json_out", default=None, help="把完整结果写入 JSON 文件")
args = ap.parse_args()
now = bj_now()
ts = timestamp_str(now)
sign = sign_of(args.appkey, args.password, ts)
order_no = args.order_no
url = args.base.rstrip("/") + args.path
refund_time = None
if args.refund_time:
try:
refund_time = dt.datetime.strptime(args.refund_time.strip(), DT_FMT)
except ValueError:
print(f"❌ --refund-time 格式应为 {DT_FMT},收到:{args.refund_time}", file=sys.stderr)
return 2
want_refund = args.refund or args.paid_then_refund
status = STATUS_REFUNDED if want_refund else STATUS_PAID
if not want_refund and not args.payload and (args.refund_reason or args.refund_time):
print(
"⚠️ --refund-reason/--refund-time 仅在 --refund 或 --paid-then-refund 下生效"
"(当前为 PAID 报文)",
file=sys.stderr,
)
if args.payload:
if want_refund:
print("⚠️ --payload 已指定,忽略 --refund/--paid-then-refund/--refund-reason/--refund-time", file=sys.stderr)
with open(args.payload, encoding="utf-8") as f:
payload = json.load(f)
else:
payload = default_payload(order_no, now, status, args.refund_reason, refund_time)
# 退款报文自检:本地先拦掉「必带字段缺失/时间格式错/超长」,避免白跑一轮联调
issues = refund_payload_issues(payload)
if issues:
print("❌ 退款报文自检未通过:", file=sys.stderr)
for it in issues:
print(f" · {it}", file=sys.stderr)
return 2
# --paid-then-refund:同一订单号先落一条 PAID,再发 REFUNDED
paid_body = None
if args.paid_then_refund and not args.payload:
paid_body = json.dumps(
default_payload(order_no, now, STATUS_PAID), ensure_ascii=False
).encode("utf-8")
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
print("=" * 78)
print("汇吉采 → 一站式 出向推送探测" + ("(退款报文)" if payload.get("status") == STATUS_REFUNDED else ""))
print("=" * 78)
print(f"目标 URL : {url}")
print(f"北京时间 : {now.strftime(DT_FMT)} → timestamp={ts}")
print(f"appKey : {args.appkey}")
print(f"sign : {sign}")
print(f"订单号 : {order_no}")
print(f"订单状态 : {payload.get('status')}")
if payload.get("status") == STATUS_REFUNDED:
print(f"退款时间 : {payload.get('refundTime')}")
print(f"退款原因 : {payload.get('refundReason')}")
print(f"请求体 : {body.decode('utf-8')}")
if paid_body is not None:
print()
print("--paid-then-refund:先发 PAID 基线)")
print(f"请求体 : {paid_body.decode('utf-8')}")
print()
if args.dry_run:
print("[dry-run] 未发送任何请求。等价 curl:")
if paid_body is not None:
print(
f" # 1) 同一订单号先落 PAID\n"
f" curl -i -X POST '{url}' \\\n"
f" -H 'Content-Type: application/json' \\\n"
f" -H 'appKey: {args.appkey}' -H 'timestamp: {ts}' -H 'sign: {sign}' \\\n"
f" -d '{paid_body.decode('utf-8')}'\n"
f" # 2) 再推 REFUNDED(带退款时间/原因)"
)
print(
f" curl -i -X POST '{url}' \\\n"
f" -H 'Content-Type: application/json' \\\n"
f" -H 'appKey: {args.appkey}' -H 'timestamp: {ts}' -H 'sign: {sign}' \\\n"
f" -d '{body.decode('utf-8')}'"
)
return 0
results: list[dict] = []
def run_case(name: str, headers: dict, note: str, target_url: str = url, use_body: bytes = body):
h = {"Content-Type": "application/json", **headers}
status_code, text, ms, err = post(target_url, use_body, h, args.timeout, args.insecure)
verdict = classify(status_code, text)
results.append(
{
"name": name,
"note": note,
"url": target_url,
"status": status_code,
"verdict": verdict,
"fingerprint": fingerprint(status_code, text),
"elapsed_ms": round(ms, 1),
"body": text,
"error": err,
}
)
shown = f"HTTP {status_code}" if status_code else "无响应"
print(f" {name:<22} {shown:<9} {VERDICT_LABEL[verdict]:<18} {brief(err or text)}")
return results[-1]
matrix = case_matrix(args.appkey, args.password, ts, sign)
# ---------------- 阶段 0:(可选)PAID 基线 ----------------
# 退款推送要针对「已在一站式存在的订单」,故先用同一订单号落一条 PAID 记录。
if paid_body is not None:
print("── 阶段 0:PAID 基线(同一订单号,先建立已支付记录)──")
run_case("0 PAID 基线", matrix[0][1], "退款前先落一条已支付记录", use_body=paid_body)
print()
# ---------------- 阶段 1:约定方案(含 repeat 幂等验证) ----------------
print(f"── 阶段 1:文档约定方案{'(连打 %d 次验幂等)' % args.repeat if args.repeat > 1 else ''} ──")
primary = run_case(matrix[0][0], matrix[0][1], matrix[0][2])
for i in range(2, args.repeat + 1):
run_case(f"1 文档约定方案 #{i}", matrix[0][1], "幂等复打")
if not args.quick:
# ---------------- 阶段 2:鉴权头变体 ----------------
print()
print("── 阶段 2:鉴权头变体矩阵(用于区分「签名不对」与「未校验签名」)──")
for name, headers, note in matrix[1:]:
run_case(name, headers, note)
# query 参数形式单独处理(不带请求头)
q = urllib.parse.urlencode(
{"appKey": args.appkey, "password": args.password, "timestamp": ts, "sign": sign}
)
run_case("8 query 参数", {}, "凭据放 URL query", target_url=f"{url}?{q}")
# ---------------- 阶段 3:对照探测 ----------------
# 仅在失败时才有诊断价值;已成功就不再给对方发多余请求。
controls: list[dict] = []
if not args.no_control and primary["verdict"] != "OK":
print()
print("── 阶段 3:对照探测(定位失败发生在哪一层)──")
parts = [p for p in args.path.strip("/").split("/") if p]
if len(parts) >= 2:
protected_nope = "/" + "/".join(parts[:-1] + ["__probe_nope__"])
reference_nope = "/" + "/".join(parts[:1] + ["auth", "__probe_nope__"])
ctl_headers = matrix[0][1]
for label, p, note in (
("C1 同前缀不存在路径", protected_nope, "若与目标接口响应一致 → 拦截发生在路由解析之前"),
("C2 另一前缀不存在路径", reference_nope, "未受保护的参照组,应为干净的 404"),
):
controls.append(
run_case(label, ctl_headers, note, target_url=args.base.rstrip("/") + p)
)
else:
print(" (路径层级不足,跳过对照探测)")
# ---------------- 结论 ----------------
print()
print("=" * 78)
print("结论")
print("=" * 78)
primary_verdict = primary["verdict"]
success = primary_verdict == "OK"
# 与「同前缀不存在路径」对照:错误语义一致说明根本没走到路由
same_as_control = bool(
controls
and controls[0]["status"] == primary["status"]
and controls[0]["fingerprint"] == primary["fingerprint"]
)
if success:
print("✅ 出向对接成功:目标接口返回 2xx,对方已接受并(应已)落盘。")
if args.repeat > 1:
ok_all = all(r["verdict"] == "OK" for r in results if r["name"].startswith("1 "))
print(
f" 幂等复打 {args.repeat} 次:{'全部 2xx ✅' if ok_all else '存在非 2xx,请检查幂等处理 ❌'}"
)
else:
print(f"❌ 出向对接失败:约定方案判定为「{VERDICT_LABEL[primary_verdict]}」。")
print()
if primary_verdict == "LOGIN_FILTER":
print("定位:请求被对方的**登录态过滤器**拒绝,与签名无关。依据:")
if same_as_control:
print(" · 同一套凭据打「同前缀下不存在的路径」,响应与真实接口**完全一致**")
print(" → 拒绝发生在路由解析之前,接口是否存在、签名是否正确都影响不到它。")
print(" · 响应体是登录错误而非「签名校验失败」,说明对方的验签逻辑没有被执行。")
print()
print("需对方处理:把该接口加入免登录白名单并实现约定验签,或提供获取调用 Token 的方式。")
elif primary_verdict == "SIGN_REJECT":
print("定位:对方已放行到验签逻辑,但签名不通过 → 属于**算法/密钥不一致**。")
print("建议:让对方用下面这组固定值离线自证,无需部署:")
print(f" appKey={args.appkey} timestamp={ts} sign={sign}")
print(" 若对方算不出同一 sign,核对 password 取值与拼接顺序(是否含分隔符、大小写)。")
elif primary_verdict == "TS_REJECT":
print("定位:时间戳被拒 → 核对对方服务器时间与北京时区,以及允许窗口(约定 ±10 分钟)。")
elif primary_verdict == "NOT_FOUND":
print("定位:路由不存在 → 路径或 base-url 不对,需对方确认接口实际地址与端口。")
elif primary_verdict == "UNREACHABLE":
print(f"定位:网络不可达 → {primary['error']}")
print("建议:确认对方服务已启动、端口开放、本机到该地址无防火墙/白名单限制。")
else:
print("未能自动归类,请把上面的响应原文提供给对方排查。")
# 变体是否出现「与基准不同」的响应——若全一致,进一步佐证签名未被校验
if not args.quick:
variants = [
r
for r in results
if r["name"][0].isdigit() and not r["name"].startswith(("0 ", "1 "))
]
distinct = {r["fingerprint"] for r in variants}
if len(distinct) <= 1 and variants:
print()
print("补充:全部鉴权头变体(含不带任何头的基准)响应**完全相同**")
print(" → 对方当前没有对请求头做任何区分,签名校验尚未生效。")
# 退款模式补充结论:PAID 基线 + REFUNDED 是否都通过(退款字段是否被对方接受)
if payload.get("status") == STATUS_REFUNDED:
print()
paid_base = next((r for r in results if r["name"].startswith("0 ")), None)
if paid_base is not None:
print(
f"PAID 基线 : {VERDICT_LABEL[paid_base['verdict']]}(同一订单号 {order_no}"
+ ("" if paid_base["verdict"] == "OK" else " ← 基线未通过,退款结果可能不可信")
)
print(
f"REFUNDED 报文: {VERDICT_LABEL[primary_verdict]}"
f"refundTime={payload.get('refundTime')} / refundReason={payload.get('refundReason')}"
)
print()
print(f"复现命令(可直接发给对方):")
if paid_body is not None:
print(
f" # 1) 同一订单号先落 PAID\n"
f" curl -i -X POST '{url}' -H 'Content-Type: application/json' \\\n"
f" -H 'appKey: {args.appkey}' -H 'timestamp: {ts}' -H 'sign: {sign}' \\\n"
f" -d '{paid_body.decode('utf-8')}'\n"
f" # 2) 再推 REFUNDED(带退款时间/原因)"
)
print(
f" curl -i -X POST '{url}' \\\n"
f" -H 'Content-Type: application/json' \\\n"
f" -H 'appKey: {args.appkey}' -H 'timestamp: {ts}' -H 'sign: {sign}' \\\n"
f" -d '{body.decode('utf-8')}'"
)
if args.json_out:
with open(args.json_out, "w", encoding="utf-8") as f:
json.dump(
{
"base": args.base,
"path": args.path,
"url": url,
"timestamp": ts,
"sign": sign,
"appKey": args.appkey,
"orderNo": order_no,
"orderStatus": payload.get("status"),
"refundTime": payload.get("refundTime"),
"refundReason": payload.get("refundReason"),
"paidThenRefund": args.paid_then_refund,
"payload": payload,
"results": results,
"controls": controls,
"verdict": primary_verdict,
"success": success,
},
f,
ensure_ascii=False,
indent=2,
)
print(f"\n完整结果已写入:{args.json_out}")
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())
@@ -65,6 +65,16 @@ public class Constants {
*/
public static final String BAD_CREDENTIALS_MSG = "请退出重新登录";
/**
* 服务暂不可用错误码(Redis 连接被重置、核心实例不可达等基础设施故障,不是用户凭据问题)
*/
public static final int SERVICE_UNAVAILABLE_CODE = 503;
/**
* 服务暂不可用提示信息
*/
public static final String SERVICE_UNAVAILABLE_MSG = "服务暂不可用,请稍后重试";
/**
* 表示升序的值
*/
@@ -5,6 +5,7 @@ import com.gxwebsoft.common.system.entity.Payment;
import com.gxwebsoft.common.system.service.PaymentService;
import com.gxwebsoft.common.system.param.PaymentParam;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.payment.service.WxPayConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -39,6 +40,9 @@ public class TestController extends BaseController {
@Autowired
private PaymentService paymentService;
@Autowired
private WxPayConfigService wxPayConfigService;
@Operation(summary = "测试LocalDateTime序列化")
@GetMapping("/datetime")
public ApiResult<Map<String, Object>> testDateTime() {
@@ -233,6 +237,8 @@ public class TestController extends BaseController {
// 清理可能的缓存键
paymentCacheService.removePaymentConfig("0", tenantId); // 微信支付
paymentCacheService.removePaymentConfig("wechat", tenantId); // 可能的其他格式
// 清理实际生效的缓存键:Payment:1:{tenantId} 与内存中的微信支付配置
wxPayConfigService.clearConfigCache(tenantId);
String result = "✅ 缓存已清理,租户ID: " + tenantId;
System.out.println(result);
@@ -0,0 +1,64 @@
package com.gxwebsoft.common.core.security;
import io.lettuce.core.RedisException;
import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.data.redis.RedisSystemException;
import java.io.IOException;
/**
* 鉴权链路上的失败分类:这次失败是<b>用户凭据问题</b>,还是<b>基础设施抖了一下</b>
*
* <p><b>为什么必须分开</b>:鉴权过滤器里除了「token 无效」,还有两次外部依赖调用——
* 核心实例的 {@code /auth/user} 与 Redis 的域名白名单读。此前它们抛出的异常与「token 无效」
* 一起被兜底成 {@code 401 请退出重新登录},于是线上出现这样一条报文:</p>
*
* <pre>
* {"code":401,"message":"请退出重新登录",
* "error":"org.springframework.data.redis.RedisSystemException: Redis exception;
* nested exception is io.lettuce.core.RedisException:
* java.net.SocketException: Connection reset"}
* </pre>
*
* <p>用户照着这句话去重新登录,什么也解决不了——Redis 连接被重置跟他手里的 token 毫无关系;
* 而真正的故障(Redis / 核心实例)被「登录态异常」这层皮盖住了,排查方向从一开始就被带偏。
* 重新登录还会把一份<b>完全有效</b>的凭据丢掉,把一次秒级抖动放大成用户的重复劳动。</p>
*
* <p><b>判定方式</b>:沿 cause 链找基础设施异常。之所以要沿链找,是因为真实抛出的异常是套娃的——
* {@code RedisSystemException → io.lettuce.core.RedisException → java.net.SocketException}、
* {@code cn.hutool.core.io.IORuntimeException → java.net.ConnectException}
* 只看最外层的类型会漏判。凭据类异常({@code MalformedJwtException} / {@code SignatureException} /
* {@code UsernameNotFoundException})的 cause 链里不会出现这些类型。</p>
*/
public final class AuthFailureUtil {
/** cause 链的遍历深度上限,防御自引用或异常实现里的环 */
private static final int MAX_CAUSE_DEPTH = 16;
private AuthFailureUtil() {
}
/**
* 是否为基础设施故障(Redis 连接被重置/超时、核心实例不可达等)。
*
* <p>这类失败<b>不该</b>报「请退出重新登录」,应报
* {@link com.gxwebsoft.common.core.Constants#SERVICE_UNAVAILABLE_MSG},让用户稍后重试即可。</p>
*
* @param e 鉴权过程中抛出的异常
* @return true 表示与用户凭据无关,是基础设施的问题
*/
public static boolean isInfrastructureFailure(Throwable e) {
Throwable t = e;
for (int depth = 0; t != null && depth < MAX_CAUSE_DEPTH; depth++) {
if (t instanceof RedisConnectionFailureException
|| t instanceof RedisSystemException
|| t instanceof RedisException
|| t instanceof IOException) {
return true;
}
Throwable cause = t.getCause();
t = (cause == t) ? null : cause;
}
return false;
}
}
@@ -107,6 +107,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
e.getMessage());
return;
} catch (Exception e) {
// Redis 连接被重置、核心实例不可达属于基础设施抖动,与用户凭据无关:
// 报「请退出重新登录」既解决不了问题,又把真正的故障掩盖成一次登录态异常。
if (AuthFailureUtil.isInfrastructureFailure(e)) {
CommonUtil.responseError(response, Constants.SERVICE_UNAVAILABLE_CODE,
Constants.SERVICE_UNAVAILABLE_MSG, e.toString());
return;
}
CommonUtil.responseError(response, Constants.BAD_CREDENTIALS_CODE, Constants.BAD_CREDENTIALS_MSG,
e.toString());
return;
@@ -79,7 +79,25 @@ public class SecurityConfig {
"/api/shop/getShopInfo",
"/api/shop/shop-order/test",
"/api/qr-code/**",
"/api/shop/order-delivery/notify"
"/api/shop/order-delivery/notify",
"/api/hjc/push/project",
// 汇吉采买家端:注册/登录/验证码/退出都由 mp-java 代理核心实例,
// 必须在登录前可访问;未登录的客户端本来也没有平台 token。
"/api/hjc/auth/register",
"/api/hjc/auth/login",
"/api/hjc/auth/logout",
"/api/hjc/auth/captcha",
"/api/hjc/auth/sms",
// 注册页专用:证件上传与 OCR 识别在登录前发生
"/api/hjc/auth/upload",
"/api/hjc/ocr/recognize",
// 小程序端登录:用 uni.login 的 code 换 openid,发生在业务请求之前,
// 小程序侧此时还没有平台 token
"/api/hjc/wechat/mp-login",
// 密码找回:企业未登录时提交材料申请(进度查询走
// GET /api/hjc/auth/password/apply/status,已被上面的 GET /** 放行)。
// 「修改密码」的两个端点必须保持需要登录态,故刻意不在此列。
"/api/hjc/auth/password/apply"
)
.permitAll()
.anyRequest()
@@ -0,0 +1,115 @@
package com.gxwebsoft.hjc.auth;
import cn.hutool.core.util.StrUtil;
import com.gxwebsoft.common.system.entity.Role;
import com.gxwebsoft.common.system.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Arrays;
import java.util.List;
/**
* 汇吉采后台管理员守卫。
*
* <p><b>为什么需要它</b>:hjc 的后台接口(企业资质分页/详情/审核、订单分页/退款、项目增删改)
* 此前<b>一个权限注解都没有</b>(全仓其他模块有 1231 处 {@code @PreAuthorize}hjc 包 0 处),
* 叠加共享 {@code SecurityConfig} 里 {@code GET /**} 放行,导致这些接口<b>匿名可读</b>
* 写接口也只要求「已登录」,任何租户的任何登录用户都能审核别家企业的资质。</p>
*
* <p><b>为什么是「按角色判」而不是「新建一个 hjc 权限串」</b>:核心实例的权限串规范是
* {@code 模块:实体:动作},其中<b>没有 hjc 命名空间</b>;而本仓对核心实例的库只有 SELECT 权限
* (无权新建菜单/权限),核心实例本身又只读。所以「hjc 管理员」只能从 core 已经给出的东西里读——
* 即 principal 上的角色。这正是 ADR-0006「账号复用核心实例」的直接收益:角色是 core 的权威,我们读它。</p>
*
* <p><b>两个条件同时满足才算管理员</b>:① 角色是 {@code admin}/{@code superAdmin}(或 core 的
* {@code is_admin} 标记位);② <b>principal 的租户是汇吉采租户</b>。第二条顺带堵住一个现存漏洞:
* 其他租户的管理员带着 {@code tenantId: 10626} 请求头就能读 hjc 数据——现在不行了,因为这里校验的是
* principal 自己的 {@code tenantId}(来自 core),而不是请求头。</p>
*/
@Slf4j
@Component("hjcGuard")
public class HjcAdminGuard {
/** 视为管理员的角色码(与 core 的 role_code 比对,忽略大小写) */
private static final List<String> ADMIN_ROLE_CODES = Arrays.asList("admin", "superadmin");
@Resource
private HjcAuthProperties hjcAuthProperties;
/** 当前登录者是否为 hjc 管理员。用于 {@code @PreAuthorize("@hjcGuard.isAdmin()")} */
public boolean isAdmin() {
User user = currentUser();
if (user == null) {
return false;
}
if (!isHjcTenant(user)) {
log.warn("HjcGuard: 拒绝非汇吉采租户的账号访问后台接口 userId={} tenantId={} hjcTenantId={}",
user.getUserId(), user.getTenantId(), hjcAuthProperties.getTenantId());
return false;
}
if (hasAdminRole(user) || Boolean.TRUE.equals(user.getIsAdmin())) {
return true;
}
log.warn("HjcGuard: 拒绝无管理员角色的账号访问后台接口 userId={} username={} roles={}",
user.getUserId(), user.getUsername(), roleCodes(user));
return false;
}
/**
* 当前登录者是否为「已登录的 hjc 买家」。
*
* <p>用于需要登录态、但不要求管理员的接口(例如订单详情要判归属)。</p>
*/
public boolean isBuyer() {
User user = currentUser();
return user != null && isHjcTenant(user);
}
/** 当前登录账号;未登录/principal 不是 core 的 User 时为 null */
public User currentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return null;
}
Object principal = authentication.getPrincipal();
return principal instanceof User ? (User) principal : null;
}
/** principal 的租户是否就是汇吉采租户 */
private boolean isHjcTenant(User user) {
Integer hjcTenantId = hjcAuthProperties.getTenantId();
return hjcTenantId != null && hjcTenantId.equals(user.getTenantId());
}
private boolean hasAdminRole(User user) {
if (user.getRoles() == null) {
return false;
}
for (Role role : user.getRoles()) {
if (role == null || StrUtil.isBlank(role.getRoleCode())) {
continue;
}
if (ADMIN_ROLE_CODES.contains(role.getRoleCode().trim().toLowerCase())) {
return true;
}
}
return false;
}
private String roleCodes(User user) {
if (user.getRoles() == null) {
return "[]";
}
StringBuilder sb = new StringBuilder("[");
for (Role role : user.getRoles()) {
if (role != null) {
sb.append(role.getRoleCode()).append(' ');
}
}
return sb.append(']').toString();
}
}
@@ -0,0 +1,26 @@
package com.gxwebsoft.hjc.auth;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* 汇吉采登录认证的配置。
*
* <p>{@code hjc.tenant-id} 是汇吉采在平台里的租户 ID(官网租户)。它有两个用途,且都必须用
* <b>配置值</b>而不是请求里带来的值:</p>
* <ol>
* <li>代理核心实例时作为 {@code TenantId} 头(核心实例的多租户插件缺该头会拼
* {@code tenant_id = NULL},导致查重静默失效、登录永不匹配);</li>
* <li>判定「hjc 管理员」时校验 principal 的租户归属。</li>
* </ol>
* <p>用配置而非请求头,是为了避免「前端把 tenantId 改成别的租户就能让 hjc 去操作别的租户」。</p>
*/
@Getter
@Component
public class HjcAuthProperties {
/** 汇吉采租户 ID(默认 10626 = 汇吉采官网) */
@Value("${hjc.tenant-id:10626}")
private Integer tenantId;
}
@@ -0,0 +1,50 @@
package com.gxwebsoft.hjc.auth;
import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.web.ApiResult;
/**
* 汇吉采买家接口的统一响应。
*
* <p><b>为什么需要它</b>:共享 {@code SecurityConfig} 里 {@code GET /**} 是放行的,
* 所以未登录的 GET 请求会**进到 controller**,由 controller 自己判登录态。
* 此前这些地方返回的是 {@code fail("用户未登录")},即 {@code code=1}——
* 与「业务失败」同一个码,前端无法据此判定登录失效。</p>
*
* <p>约定(与平台既有常量一致,不新造码值):</p>
* <ul>
* <li>{@code 401} = 未登录 / 凭据失效 → 前端应清凭据并跳登录页</li>
* <li>{@code 403} = 已登录但无权限(由 {@code @PreAuthorize} 抛
* {@code AccessDeniedException} 产生)→ 前端只提示,不清凭据</li>
* </ul>
*
* <p>复用 {@link Constants#UNAUTHENTICATED_CODE} 与 {@link Constants#UNAUTHENTICATED_MSG}
* 这两个常量本就为此准备,此前唯一引用处是被注释掉的代码。</p>
*/
public final class HjcAuthResponses {
private HjcAuthResponses() {
}
/**
* 未登录 / 凭据失效:{@code code=401, message="请先登录"}。
*
* <p>用于 controller 内自行判定登录态的场景(GET 放行时不会经过安全过滤器)。</p>
*/
public static <T> ApiResult<T> unauthorized() {
return new ApiResult<>(Constants.UNAUTHENTICATED_CODE, Constants.UNAUTHENTICATED_MSG);
}
/**
* 已登录但无权访问该资源:{@code code=403}。
*
* <p>与 {@code @PreAuthorize} 抛出的 {@code AccessDeniedException} 归为同一个码,
* 使前端只需记一套规则:401 清凭据跳登录、403 只提示。</p>
*
* @param message 具体原因(例如「无权查看该订单」),直接展示给用户
*/
public static <T> ApiResult<T> forbidden(String message) {
return new ApiResult<>(Constants.UNAUTHORIZED_CODE,
message == null ? Constants.UNAUTHORIZED_MSG : message);
}
}
@@ -0,0 +1,277 @@
package com.gxwebsoft.hjc.auth;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.gxwebsoft.common.core.config.ConfigProperties;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Map;
/**
* 汇吉采登录认证对核心实例的调用出口。
*
* <p><b>为什么 hjc 的注册/登录要调核心实例</b>:平台的身份体系只有一套(核心实例
* `gxwebsoft_core.sys_user`),C 端用户与管理员同源;mp-java 是模块实例,<b>本库没有</b>
* `sys_user/sys_role/sys_menu/sys_user_role` 这些表,且模块库账号对 `gxwebsoft_core`
* <b>只有 SELECT 权限</b>(无法跨库建号)。因此建号与口令校验只能走核心实例的 HTTP 接口。
* 见 ADR-0006。</p>
*
* <p><b>三条硬性实现约束</b>(否则功能必然坏,且坏得很难查):</p>
* <ol>
* <li><b>必须带 {@code TenantId} 请求头</b>:核心实例的 MyBatis-Plus 多租户插件在缺少该头时
* 会把条件拼成 {@code tenant_id = NULL}MySQL 恒不命中)→ 查重静默失效、登录查询永不匹配。
* 这里统一用 {@link HjcAuthProperties} 的配置值注入,不采用请求头里的值。</li>
* <li><b>必须剥掉 Authorization</b>:核心实例存短信验证码的 Redis 键前缀取决于请求
* <i>是否已认证</i>(未认证 = {@code cache{手机号}},已认证 = {@code cache{租户}:{手机号}}),
* 而注册读的是<b>未认证</b>那个。若把 hjc 前端的旧 token 转发过去,发码与校验会落到不同的键,
* 表现为莫名其妙的「验证码不正确」。</li>
* <li><b>不转发邮箱</b>:核心实例注册时会把<b>明文密码</b>连同品牌域名(硬编码
* {@code websoft.top})邮件发给用户。hjc 的企业邮箱/经办人邮箱只存 hjc 自己的表。</li>
* </ol>
*/
@Slf4j
@Component
public class HjcCoreAuthClient {
/** 核心实例:账号密码登录(带图形验证码) */
static final String LOGIN_PATH = "/login";
/** 核心实例:账号注册(带短信验证码) */
static final String REGISTER_PATH = "/register";
/** 核心实例:图形验证码 */
static final String CAPTCHA_PATH = "/captcha";
/** 核心实例:发送短信验证码 */
static final String SMS_PATH = "/sendSmsCaptcha";
/** 核心实例:修改自己的密码(需登录态 token + `sys:auth:password` 权限) */
static final String PASSWORD_PATH = "/auth/password";
/** 核心实例:重置密码(匿名,凭短信/邮箱验证码) */
static final String RESET_PASSWORD_PATH = "/resetPassword";
static final int TIMEOUT_MS = 10000;
@Resource
private ConfigProperties configProperties;
@Resource
private HjcAuthProperties hjcAuthProperties;
/**
* 核心实例的响应。核心实例的业务失败是 <b>HTTP 200 + code != 0</b>(不是 HTTP 4xx),
* 所以判定成功一律看 {@code code}。
*/
@Data
public static class CoreResult {
/** code == 0 即成功 */
private boolean ok;
private int code;
private String message;
private String error;
/** 成功时的 data(核心实例的 LoginResult / CaptchaResult);不是对象时留空,明细看 {@link #raw} */
private JSONObject data;
/** 原始响应体,仅用于日志与异常排查 */
private String raw;
static CoreResult of(String raw) {
CoreResult r = new CoreResult();
r.raw = raw;
JSONObject json;
try {
json = JSON.parseObject(raw);
} catch (Exception e) {
r.code = -1;
r.message = "认证服务返回异常";
r.error = "响应不是合法 JSON";
return r;
}
if (json == null) {
r.code = -1;
r.message = "认证服务返回异常";
r.error = "响应为空";
return r;
}
r.code = json.getIntValue("code");
r.message = json.getString("message");
r.error = json.getString("error");
r.data = toObjectData(json.get("data"));
r.ok = r.code == 0;
return r;
}
/**
* 把 {@code data} 取成 JSONObject<b>取不到就是 null,绝不抛异常</b>。
*
* <p>不能用 {@code json.getJSONObject("data")}fastjson 见到 String 会当成 JSON 再解析一次,
* 而核心实例的 {@code data} 是 {@code Object}{@code /sendSmsCaptcha} 成功时给的就是阿里云的
* 字符串 {@code "OK"}core 里是 {@code success("发送成功", result.get("Message"))})。
* 解析 {@code "OK"} 抛的 {@code JSONException} 会一路逃到 {@link #execute} 的兜底 catch
* 被改写成「认证服务暂不可用,请稍后重试」——<b>短信其实已经发出去了,用户却看到发送失败</b>,
* 核心实例那侧 60 秒重发限制和当日计数还已经消耗掉了。</p>
*
* <p>调用方一律按 {@code code} 判成败;{@code data} 不是对象时不需要它的内容,要明细读 {@code raw}。</p>
*/
private static JSONObject toObjectData(Object value) {
if (value instanceof JSONObject) {
return (JSONObject) value;
}
// fastjson 解析出来的嵌套对象可能只是普通 Map(不是 com.alibaba.fastjson.JSONObject 的实例)
if (value instanceof Map) {
return new JSONObject((Map<String, Object>) value);
}
return null;
}
}
/**
* 账号密码登录。
*
* <p>{@code captchaCode} 为图形验证码。<b>刻意不传 {@code isSuperAdmin}</b>:核心实例只要收到
* 该字段非 null(连 {@code false} 都算)就整段跳过图形验证码校验,那就等于我们自己把这道门拆了。</p>
*/
public CoreResult login(String username, String password, String captchaCode) {
JSONObject body = new JSONObject();
body.put("username", username);
body.put("password", password);
body.put("code", captchaCode);
body.put("tenantId", hjcAuthProperties.getTenantId());
return post(LOGIN_PATH, body);
}
/** 账号注册。不传 email(见类注释第 3 条) */
public CoreResult register(String username, String phone, String password, String smsCode) {
JSONObject body = new JSONObject();
body.put("username", username);
body.put("phone", phone);
body.put("password", password);
body.put("code", smsCode);
// 必须显式 false:该字段为 true 的分支会连带创建租户,而我们只要一个普通注册用户
body.put("isSuperAdmin", Boolean.FALSE);
return post(REGISTER_PATH, body);
}
/**
* 取图形验证码。
*
* <p><b>注意</b>:核心实例的响应体里含明文答案 {@code text}(它把答案也返回给客户端)。
* 调用方<b>必须丢弃</b>该字段,只把图片下发给前端,否则这道验证码等于不存在。</p>
*/
public CoreResult captcha() {
return execute(HttpRequest.get(url(CAPTCHA_PATH)), CAPTCHA_PATH);
}
/**
* 发送短信验证码。
*
* <p><b>注册场景不要传 scene</b>:核心实例在 {@code scene=login} 时会先校验该手机号<b>已注册</b>
* 而注册恰恰是给未注册的手机号发码,传了会被拒(「该手机号码未注册!」)。</p>
*/
public CoreResult sendSmsCaptcha(String phone) {
JSONObject body = new JSONObject();
body.put("phone", phone);
return post(SMS_PATH, body);
}
/**
* 校验旧密码(<b>唯一一条要转发买家 Authorization 的调用</b>)。
*
* <p><b>为什么这里与类注释第 2 条相反</b>:核心实例的 {@code PUT /auth/password} 按<b>登录态</b>
* 识别要改谁的密码,不带 token 根本无从校验。它只碰密码,不碰短信验证码与图形验证码,
* 因此不存在"Redis 键前缀随认证姿态变化"那个问题。</p>
*
* <p><b>为什么可以传「新密码 = 旧密码」</b>:该接口只校验 {@code oldPassword} 是否正确,
* <b>不比较新旧密码是否相同</b>(见 core MainController#updatePassword),因此这次调用
* 只做校验、不改变可登录的口令(代价是用同一明文重写了一次 BCrypt 哈希)。
* 之所以需要它,是因为短信验证码只有核心实例的 {@code /resetPassword} 会校验,而那个接口
* <b>同时就把密码改了</b>——双因子要各自独立成立,旧密码就必须先单独验掉。</p>
*
* <p><b>前置条件</b>:该账号必须拥有 {@code sys:auth:password}。hjc 买家角色(租户 10626 的
* {@code user})当前是 0 菜单 0 权限,需由运维在核心实例后台授予,否则这里恒 403。</p>
*
* @param authorization 买家自己的 {@code Bearer xxx},原样转发
*/
public CoreResult verifyOldPassword(String authorization, String oldPassword) {
JSONObject body = new JSONObject();
body.put("oldPassword", oldPassword);
body.put("password", oldPassword);
return execute(HttpRequest.put(url(PASSWORD_PATH))
.header("Content-Type", "application/json;charset=UTF-8")
.header("Authorization", authorization)
.body(body.toJSONString())
.timeout(TIMEOUT_MS), PASSWORD_PATH);
}
/**
* 重置密码(匿名姿态,<b>必须剥掉 Authorization</b>)。
*
* <p><b>必须带 {@code userId} + {@code tenantId}</b>:不传 {@code userId} 时核心实例会按手机号
* 查出<b>所有租户</b>下的账号并逐个改密(其 SQL 无 {@code tenant_id} 条件),那会连带改掉
* 同一手机号在其它平台的账号密码。这里锁定单个账号。</p>
*/
public CoreResult resetPassword(Integer userId, Integer tenantId, String phone, String smsCode,
String newPassword, String confirmPassword) {
JSONObject body = new JSONObject();
body.put("userId", String.valueOf(userId));
body.put("tenantId", tenantId);
body.put("phone", phone);
body.put("smsCode", smsCode);
body.put("newPassword", newPassword);
body.put("confirmPassword", confirmPassword);
return post(RESET_PASSWORD_PATH, body);
}
private CoreResult post(String path, JSONObject body) {
return execute(HttpRequest.post(url(path))
.header("Content-Type", "application/json;charset=UTF-8")
.body(body.toJSONString())
.timeout(TIMEOUT_MS), path);
}
/**
* 唯一的 HTTP 出口(测试中可覆盖以注入打桩响应)。
*
* <p>只带 {@code TenantId}<b>不转发 Authorization</b>(见类注释第 2 条)。</p>
*/
protected String doExecute(HttpRequest request, Integer tenantId, String path) {
if (tenantId != null) {
request.header("TenantId", String.valueOf(tenantId));
}
String body = request.execute().body();
log.info("HjcCoreAuth: {} tenantId={} → {}", path, tenantId, brief(body));
return body;
}
private CoreResult execute(HttpRequest request, String path) {
Integer tenantId = hjcAuthProperties.getTenantId();
try {
CoreResult r = CoreResult.of(doExecute(request, tenantId, path));
if (!r.isOk()) {
log.warn("HjcCoreAuth: {} 失败 code={} message={} error={}", path, r.getCode(), r.getMessage(), r.getError());
}
return r;
} catch (Exception e) {
log.error("HjcCoreAuth: {} 调用核心实例异常 tenantId={} serverUrl={}", path, tenantId, serverUrl(), e);
CoreResult r = new CoreResult();
r.setCode(-1);
r.setMessage("认证服务暂不可用,请稍后重试");
r.setError(e.toString());
return r;
}
}
private String url(String path) {
return serverUrl() + path;
}
private String serverUrl() {
return StrUtil.removeSuffix(configProperties.getServerUrl(), "/");
}
private static String brief(String text) {
if (text == null) {
return null;
}
return text.length() <= 300 ? text : text.substring(0, 300) + "...";
}
}
@@ -0,0 +1,368 @@
package com.gxwebsoft.hjc.controller;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.common.system.result.LoginResult;
import com.gxwebsoft.hjc.auth.HjcAuthProperties;
import com.gxwebsoft.hjc.auth.HjcCoreAuthClient;
import com.gxwebsoft.hjc.dto.HjcAuthRequest;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial;
import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import com.gxwebsoft.hjc.util.HjcOssUploadUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 汇吉采 注册 / 登录 / 退出 / 验证码(企业名称 + 密码)。
*
* <p><b>账号归核心实例,企业归汇吉采</b>ADR-0006):本控制器<b>不写任何账号表</b>
* 注册与登录都代理给核心实例({@link HjcCoreAuthClient});汇吉采只写自己的企业档案
* {@code hjc_enterprise} / {@code hjc_enterprise_material}。</p>
*
* <p><b>为什么不再直接用 {@code userService}</b>mp-java 的 {@code common} 层是核心实例 2023 年的
* 旧快照,其中身份表 SQL 漏库名({@code UserMapper.xml} 的坏 JOIN、{@code RoleMenuMapper.xml}、
* {@code TenantMapper.xml}),且 {@code sys_user} 根本不在模块库。走它必然失败或读错库。
* 故本控制器<b>不调用 {@code getByUsername} 等任何身份 SQL</b>,企业名重名交给核心实例报错。</p>
*/
@Tag(name = "汇吉采-企业登录")
@Slf4j
@RestController
@RequestMapping("/api/hjc/auth")
public class HjcAuthController extends BaseController {
@Resource
private HjcCoreAuthClient coreAuthClient;
@Resource
private HjcAuthProperties hjcAuthProperties;
@Resource
private HjcEnterpriseService hjcEnterpriseService;
@Resource
private HjcOssUploadUtil hjcOssUploadUtil;
/**
* 解析核心实例返回的 user 时使用的 ObjectMapper。
*
* <p><b>必须忽略未知字段</b>:核心实例的 {@code User} 实现了 Spring Security 的
* {@code UserDetails},因此会多序列化 {@code enabled}、{@code accountNonExpired}、
* {@code accountNonLocked}、{@code credentialsNonExpired} 四个字段,而模块实例这份
* 2023 年的旧 {@code User} 副本里没有它们。用默认配置反序列化会直接抛
* {@code UnrecognizedPropertyException}。</p>
*
* <p>这正是教训所在:这个异常在<b>核心实例已经建号成功之后</b>抛出,一度被当成「注册失败」
* 返回给用户,于是账号已存在而用户被告知失败、重试又撞「手机号已存在」。故除了放宽解析,
* 注册流程也不再依赖整个 User 能否解析成功(见 {@link #extractUserId})。</p>
*/
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
/** 注册必传的资质证件材料类型 */
private static final List<String> REQUIRED_MATERIAL_TYPES =
Arrays.asList("idcard_front", "idcard_back", "handbook", "license");
/** 与核心实例的短信发送校验一致:中国大陆手机号 */
private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
/**
* 登录失败时可直接透传给用户的文案白名单。
*
* <p>核心实例登录失败一律返回 {@code code=1}message 各异;其中 {@code 操作失败} 是它的兜底文案
* (既可能是「密码错误」路径上的空指针,也可能是别的意外错误),<b>无法区分</b>。登录接口不该把内部
* 错误暴露给用户,故:白名单内原样透传,其余一律归为「企业名称或密码错误」,同时把<b>原始响应</b>
* 记入日志以便运维发现异常。</p>
*/
private static final List<String> PASSTHROUGH_LOGIN_MESSAGES = Arrays.asList(
"账号不存在", "密码错误", "账号被冻结", "图形验证码不正确", "图形验证码不能为空",
"密码错误次数过多,请10分钟后重试");
@Operation(summary = "图形验证码(登录用;只返回图片,不下发答案)")
@GetMapping("/captcha")
public ApiResult<Map<String, Object>> captcha() {
HjcCoreAuthClient.CoreResult result = coreAuthClient.captcha();
if (!result.isOk() || result.getData() == null) {
return fail(StrUtil.blankToDefault(result.getMessage(), "验证码获取失败"), null);
}
String base64 = result.getData().getString("base64");
if (StrUtil.isBlank(base64)) {
return fail("验证码获取失败", null);
}
// 核心实例的响应里带明文答案 text,这里必须丢弃:否则这道验证码等于不存在
Map<String, Object> payload = new HashMap<>(2);
payload.put("image", base64.startsWith("data:") ? base64 : "data:image/png;base64," + base64);
return success(payload);
}
@Operation(summary = "发送注册短信验证码(发到经办人手机号)")
@PostMapping("/sms")
public ApiResult<?> sms(@RequestBody Map<String, String> body) {
String phone = body == null ? null : StrUtil.trimToNull(body.get("phone"));
if (phone == null) {
return fail("手机号不能为空");
}
if (!PHONE_PATTERN.matcher(phone).matches()) {
return fail("请输入正确的手机号");
}
// 注册场景不传 scene:核心实例的 scene=login 会先要求该手机号「已注册」
HjcCoreAuthClient.CoreResult result = coreAuthClient.sendSmsCaptcha(phone);
if (!result.isOk()) {
return fail(StrUtil.blankToDefault(result.getMessage(), "验证码发送失败"));
}
return success("验证码已发送", null);
}
@Operation(summary = "企业登录(企业名称+密码+图形验证码)")
@PostMapping("/login")
public ApiResult<LoginResult> login(@RequestBody HjcAuthRequest request) {
if (request == null || StrUtil.isBlank(request.getEnterpriseName()) || StrUtil.isBlank(request.getPassword())) {
return fail("企业名称和密码不能为空", null);
}
if (StrUtil.isBlank(request.getCode())) {
return fail("图形验证码不能为空", null);
}
HjcCoreAuthClient.CoreResult result = coreAuthClient.login(
StrUtil.trim(request.getEnterpriseName()),
request.getPassword(),
// 核心实例以「小写验证码」作为 Redis 键存储并比对,用户输入的大小写必须先归一
StrUtil.trim(request.getCode()).toLowerCase());
if (!result.isOk()) {
String message = result.getMessage();
if (message == null || !PASSTHROUGH_LOGIN_MESSAGES.contains(message)) {
log.warn("HjcAuth: 登录失败已映射为通用文案,原始 message={} error={} raw={}",
message, result.getError(), result.getRaw());
message = "企业名称或密码错误";
}
return fail(message, null);
}
LoginResult loginResult = toLoginResult(result);
if (loginResult == null) {
return fail("登录失败,请稍后重试", null);
}
return success("登录成功", loginResult);
}
@Operation(summary = "企业注册(一站式:核心实例建号 + 企业/经办人/授权/证件入库,提交后待审核)")
@PostMapping("/register")
public ApiResult<LoginResult> register(@RequestBody HjcAuthRequest request) {
if (request == null || StrUtil.isBlank(request.getEnterpriseName()) || StrUtil.isBlank(request.getPassword())) {
return fail("企业名称和密码不能为空", null);
}
String invalid = validateRegister(request);
if (invalid != null) {
return fail(invalid, null);
}
String enterpriseName = StrUtil.trim(request.getEnterpriseName());
// 账号手机号 = 经办人手机号(需求:经办人手机号需核验)
String phone = StrUtil.trim(request.getAgentPhone());
// 第一步:核心实例建号。这是「先建号、后写档案」的两步操作,之间没有事务,
// 因此凡是能在建号前判定的问题都已在 validateRegister 挡掉,让这一步成为最后一道关。
HjcCoreAuthClient.CoreResult result = coreAuthClient.register(
enterpriseName, phone, request.getPassword(), StrUtil.trim(request.getCode()));
if (!result.isOk()) {
// 核心实例会把 BusinessException 的 message 原样返回(账号已存在/手机号已存在/验证码不正确…),直接透传
log.warn("HjcAuth: 注册被核心实例拒绝 message={} error={} raw={}",
result.getMessage(), result.getError(), result.getRaw());
return fail(StrUtil.blankToDefault(result.getMessage(), "注册失败"), null);
}
// 建号已成功,此后任何失败都不能再说「注册失败」——账号已经存在了。
// 先用最小依赖的方式取 userId(只读一个 JSON 数字),不依赖整个 User 能否反序列化。
Integer userId = extractUserId(result);
if (userId == null) {
log.error("HjcAuth: 核心实例注册成功但未返回 userId,raw={}", result.getRaw());
return fail("账号已创建,但未取到账号信息。请用「" + enterpriseName
+ "」直接登录,并联系管理员核对。", null);
}
// 第二步:写汇吉采自己的企业档案(独立事务)。此处失败时账号已存在于核心实例,
// 故必须明确告知用户「账号已建、资质未存」,让其登录后用资质页补交,而不是抛一句「操作失败」。
HjcEnterprise enterprise = new HjcEnterprise();
enterprise.setUserId(userId);
enterprise.setName(enterpriseName);
enterprise.setCreditCode(request.getCreditCode());
enterprise.setContactPhone(request.getContactPhone());
enterprise.setContactEmail(request.getContactEmail());
enterprise.setAddress(request.getAddress());
enterprise.setAgentName(request.getAgentName());
enterprise.setAgentEmail(request.getAgentEmail());
enterprise.setAgentPhone(phone);
enterprise.setIdCardNo(request.getIdCardNo());
enterprise.setAuthorizeExpire(request.getAuthorizeExpire());
enterprise.setAuthStatus(0);
enterprise.setTenantId(hjcAuthProperties.getTenantId());
try {
hjcEnterpriseService.saveRegistration(enterprise, request.getMaterials());
} catch (Exception e) {
log.error("HjcAuth: 账号已创建但企业资质入库失败 userId={} enterpriseName={}",
userId, enterpriseName, e);
return fail("账号已创建,但资质信息保存失败。请用「" + enterpriseName
+ "」登录后,在资质页重新提交。", null);
}
LoginResult loginResult = toLoginResult(result);
if (loginResult == null) {
return fail("注册失败,请稍后重试", null);
}
return success("注册成功", loginResult);
}
@Operation(summary = "退出登录(无服务端会话,仅前端清 token)")
@PostMapping("/logout")
public ApiResult<?> logout() {
// 平台不做服务端登出,也不维护 token 黑名单(核心实例自身也禁用了 logout);
// token 是无状态 JWT,前端清除本地凭据即完成退出。
return success("已退出登录", null);
}
@Operation(summary = "注册证件上传(匿名,注册页与资质页通用;文件存入 OSS)")
@PostMapping("/upload")
public ApiResult<Map<String, Object>> upload(@RequestParam("file") MultipartFile file) {
if (file == null || file.isEmpty()) {
return fail("上传文件不能为空", null);
}
try {
// 委托统一 OSS 上传服务(server.websoft.top/api/oss/upload):它按租户取云存储配置、
// 把文件写进 OSS 并返回可访问地址。此前这里只写本地磁盘再拼 OSS 域名,文件从未进过 OSS。
String url = hjcOssUploadUtil.upload(file, String.valueOf(hjcAuthProperties.getTenantId()));
Map<String, Object> data = new HashMap<>(4);
data.put("url", url);
data.put("name", file.getOriginalFilename());
return success(data);
} catch (Exception e) {
log.warn("HjcAuth: 证件上传失败 tenantId={} name={} size={}",
hjcAuthProperties.getTenantId(), file.getOriginalFilename(), file.getSize(), e);
return fail("上传失败", null);
}
}
/**
* 注册必填校验:企业联系电话、企业地址选填,其余必填;4 类证件必传。
*
* <p>这是「让核心实例调用成为最后一步」的关键:凡能在建号前判定的问题都必须在此挡掉,
* 否则会在核心实例里留下一个没有企业档案的孤儿账号。</p>
*
* @return 错误信息;校验通过返回 null
*/
private String validateRegister(HjcAuthRequest request) {
if (StrUtil.isBlank(request.getCode())) {
return "短信验证码不能为空";
}
if (StrUtil.isBlank(request.getAgentPhone())) {
return "经办人手机号不能为空";
}
if (!PHONE_PATTERN.matcher(StrUtil.trim(request.getAgentPhone())).matches()) {
return "请输入正确的经办人手机号";
}
if (StrUtil.isBlank(request.getCreditCode())) {
return "纳税人识别号不能为空";
}
if (StrUtil.isBlank(request.getContactEmail())) {
return "企业邮箱不能为空";
}
if (StrUtil.isBlank(request.getAgentName())) {
return "经办人姓名不能为空";
}
if (StrUtil.isBlank(request.getAgentEmail())) {
return "经办人邮箱不能为空";
}
if (StrUtil.isBlank(request.getIdCardNo())) {
return "经办人身份证号不能为空";
}
if (request.getAuthorizeExpire() == null) {
return "授权到期时间不能为空";
}
List<HjcEnterpriseMaterial> materials = request.getMaterials();
for (String type : REQUIRED_MATERIAL_TYPES) {
boolean present = materials != null && materials.stream()
.anyMatch(m -> type.equals(m.getMaterialType()) && StrUtil.isNotBlank(m.getFileUrl()));
if (!present) {
return "请上传全部证件(身份证正反面、授权委托书、营业执照)";
}
}
return null;
}
/**
* 把核心实例的响应转成前端的登录结果,并<b>剔除敏感字段</b>。
*
* <p>核心实例的 {@code /login}、{@code /register} 会把整个 {@code User} 原样返回,
* <b>其中含 BCrypt 密码哈希</b>(它不脱敏)。直接把该对象回传前端等于把密码哈希交给浏览器,
* 故这里重新构造:只保留展示字段,清空密码、支付密码与权限集合。</p>
*/
private LoginResult toLoginResult(HjcCoreAuthClient.CoreResult result) {
JSONObject data = result.getData();
if (data == null) {
return null;
}
String token = data.getString("access_token");
if (StrUtil.isBlank(token)) {
log.error("HjcAuth: 核心实例未返回 access_tokenraw={}", result.getRaw());
return null;
}
return new LoginResult(token, parseUser(result));
}
/** 解析核心实例返回的 user(保留展示字段,清空凭据与权限) */
private User parseUser(HjcCoreAuthClient.CoreResult result) {
JSONObject data = result.getData();
JSONObject userJson = data == null ? null : data.getJSONObject("user");
if (userJson == null) {
return null;
}
User user;
try {
user = OBJECT_MAPPER.readValue(userJson.toJSONString(), User.class);
} catch (Exception e) {
// 放宽解析后仍失败(例如核心实例将来加了不兼容的字段类型):不阻断主流程,
// 退回只带展示字段的最小对象,至少让前端拿到本次登录/注册的账号是谁。
log.warn("HjcAuth: 核心实例 user 完整解析失败,退回最小字段。原因={}", e.toString());
user = minimalUser(userJson);
}
user.setPassword(null);
user.setPayPassword(null);
user.setRoles(null);
user.setAuthorities(null);
return user;
}
/**
* 只从响应里取 userId。与整个 User 的反序列化解耦——
* 注册成功后的关键信息只有 userId,若因为解析 User 失败就当作「注册失败」,
* 会留下「核心实例有账号、汇吉采无档案」的孤儿账号,且用户被误导去重试。
*/
private Integer extractUserId(HjcCoreAuthClient.CoreResult result) {
JSONObject data = result.getData();
JSONObject userJson = data == null ? null : data.getJSONObject("user");
return userJson == null ? null : userJson.getInteger("userId");
}
/** 最小可用账号对象:仅在完整解析失败时兜底,字段与前端展示一致 */
private User minimalUser(JSONObject userJson) {
User user = new User();
user.setUserId(userJson.getInteger("userId"));
user.setUsername(userJson.getString("username"));
user.setNickname(userJson.getString("nickname"));
user.setPhone(userJson.getString("phone"));
user.setTenantId(userJson.getInteger("tenantId"));
return user;
}
}
@@ -0,0 +1,38 @@
package com.gxwebsoft.hjc.controller;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.hjc.dto.HjcBannerVo;
import com.gxwebsoft.hjc.service.HjcBannerService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
/**
* 汇吉采首页轮播图(C 端只读)
*
* <p>数据源为 CMS 后台维护的轮播组(cms_banner_group / cms_banner_item),
* 运营在管理后台配置图片、跳转与生效时间即可生效,无需改前端代码。
* 仅提供 GET,未登录也可访问(用于首页首屏)。</p>
*/
@Tag(name = "汇吉采-首页轮播")
@RestController
@RequestMapping("/api/hjc/banner")
public class HjcBannerController extends BaseController {
@Resource
private HjcBannerService hjcBannerService;
@Operation(summary = "启用中的轮播图列表(C端)")
@GetMapping("/list")
public ApiResult<List<HjcBannerVo>> list(
@RequestParam(value = "position", required = false) String position) {
return success(hjcBannerService.listEnabled(position));
}
}
@@ -0,0 +1,159 @@
package com.gxwebsoft.hjc.controller;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.hjc.auth.HjcAdminGuard;
import com.gxwebsoft.hjc.entity.HjcBidProject;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.param.HjcBidProjectParam;
import com.gxwebsoft.hjc.service.HjcBidProjectService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import com.gxwebsoft.hjc.service.HjcOrderService;
import com.gxwebsoft.hjc.service.HjcProjectFavoriteService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.List;
/**
* 汇吉采标书项目(C端浏览 + 管理后台维护)
*
* <p><b>C 端接口不下发标书附件的下载地址</b>{@code tenderFile}):它是付费内容,
* 只在订单接口上、且订单已支付时才给(见 {@code HjcOrderController#attachTenderFiles})。
* 本类里 {@code list} 恒置 null、{@code detail} 只对「管理员」或「已为本项目付过款的买家」放行。
* 不做这一步的话,UI 上加多少门槛都只是装饰——匿名 curl 一次就绕过了。</p>
*/
@Tag(name = "汇吉采-标书项目")
@RestController
@RequestMapping("/api/hjc/bid-project")
public class HjcBidProjectController extends BaseController {
@Resource
private HjcBidProjectService hjcBidProjectService;
@Resource
private HjcOrderService hjcOrderService;
@Resource
private HjcEnterpriseService hjcEnterpriseService;
@Resource
private HjcProjectFavoriteService hjcProjectFavoriteService;
@Resource
private HjcAdminGuard hjcGuard;
@Operation(summary = "分页查询(后台)")
@GetMapping("/page")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<PageResult<HjcBidProject>> page(HjcBidProjectParam param) {
// 后台**保留** tenderFilehjc-vue 的编辑表单要靠它回填
return success(hjcBidProjectService.pageRel(param));
}
@Operation(summary = "在售列表(C端,仅上架)")
@GetMapping("/list")
public ApiResult<PageResult<HjcBidProject>> list(HjcBidProjectParam param) {
param.setStatus(1);
PageResult<HjcBidProject> page = hjcBidProjectService.pageRel(param);
// C 端列表从不渲染附件,而这里是**匿名可读**的:一律不下发地址(后台走 /page,不受影响)
List<HjcBidProject> list = page.getList();
if (list != null) {
for (HjcBidProject project : list) {
if (project != null) {
project.setTenderFile(null);
}
}
}
return success(page);
}
@Operation(summary = "详情")
@GetMapping("/{id}")
public ApiResult<?> detail(@PathVariable("id") Integer id) {
HjcBidProject project = hjcBidProjectService.getById(id);
if (project == null) {
return fail("标书项目不存在");
}
// 「购买人数」= 实时统计的**已付款订单数**(不是 saleCount:那个字段下单即加、从不回退,
// 含未付款与已取消的单,拿它当购买人数一直是假数)。详见 HjcBidProject#buyerCount。
project.setBuyerCount(hjcOrderService.countPaidOrders(project.getId()));
// 收藏态(非表字段,见 HjcBidProject#favorited):详情**内联**返回它,让前端一次请求
// 就能画出收藏按钮的正确状态,避免「页面已渲染、心形图标稍后跳一下」。
// 未登录 / 未注册企业一律 false —— 详情是公开接口,**不能**在这里返回 401,
// 否则会把「收藏态查不到」升级成「整个详情页看不了」。
project.setFavorited(false);
// 登录态只解析一次企业,收藏态与「是否已购买」共用(别查两遍)
HjcEnterprise enterprise = null;
Integer userId = getLoginUserId();
if (userId != null) {
enterprise = hjcEnterpriseService.getByUserId(userId);
if (enterprise != null) {
project.setFavorited(hjcProjectFavoriteService.exists(enterprise.getId(), project.getId()));
}
}
// 附件下载地址是付费内容:管理员(后台编辑表单要回填)或已为本项目付过款的买家才拿到,
// 其余(含匿名)一律抹掉。判定口径复用订单侧既有的 pay_status = 1,不新造一套。
if (!isTenderFileVisible(project.getId(), enterprise)) {
project.setTenderFile(null);
}
return success(project);
}
/**
* 本次请求是否有权看到该项目的标书附件地址。
*
* <p>两条放行路径:① hjc 管理员;② 登录买家且其企业在本项目上有已付款订单。</p>
*/
private boolean isTenderFileVisible(Integer projectId, HjcEnterprise enterprise) {
if (hjcGuard.isAdmin()) {
return true;
}
if (enterprise == null) {
return false;
}
return !hjcOrderService.listPaidProjectIds(enterprise.getId(), Collections.singletonList(projectId))
.isEmpty();
}
@Operation(summary = "新增(后台)")
@PostMapping()
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> save(@RequestBody HjcBidProject project) {
project.setId(null);
if (project.getTenantId() == null) {
project.setTenantId(getTenantId());
}
if (project.getStatus() == null) {
project.setStatus(1);
}
if (project.getDataSource() == null) {
project.setDataSource("MANUAL");
}
if (project.getSaleCount() == null) {
project.setSaleCount(0);
}
hjcBidProjectService.save(project);
return success("保存成功", project.getId());
}
@Operation(summary = "更新(后台)")
@PutMapping()
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> update(@RequestBody HjcBidProject project) {
if (project.getId() == null) {
return fail("ID不能为空");
}
hjcBidProjectService.updateById(project);
return success("更新成功");
}
@Operation(summary = "删除/下架(后台)")
@DeleteMapping("/{id}")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> delete(@PathVariable("id") Integer id) {
hjcBidProjectService.removeById(id);
return success("删除成功");
}
}
@@ -0,0 +1,136 @@
package com.gxwebsoft.hjc.controller;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.auth.HjcAuthResponses;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial;
import com.gxwebsoft.hjc.param.HjcEnterpriseParam;
import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
/**
* 汇吉采企业账号与资质
*/
@Tag(name = "汇吉采-企业资质")
@RestController
@RequestMapping("/api/hjc/enterprise")
public class HjcEnterpriseController extends BaseController {
@Resource
private HjcEnterpriseService hjcEnterpriseService;
@Resource
private HjcEnterpriseMaterialService hjcEnterpriseMaterialService;
@Operation(summary = "当前登录企业的资料")
@GetMapping("/my")
public ApiResult<?> my() {
Integer userId = getLoginUserId();
if (userId == null) {
return HjcAuthResponses.unauthorized();
}
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId);
if (enterprise != null) {
enterprise.setMaterials(listMaterials(enterprise.getId()));
enterprise.setMobile(getLoginUser().getMobile());
}
return success(enterprise);
}
@Operation(summary = "提交/更新企业资质(信息+证件),提交后回到待审核")
@PostMapping("/save")
@Transactional(rollbackFor = Exception.class)
public ApiResult<?> save(@RequestBody HjcEnterprise enterprise) {
Integer userId = getLoginUserId();
if (userId == null) {
return HjcAuthResponses.unauthorized();
}
HjcEnterprise exist = hjcEnterpriseService.getByUserId(userId);
boolean isNew = exist == null;
if (isNew) {
enterprise.setId(null);
enterprise.setUserId(userId);
enterprise.setAuthStatus(0);
if (enterprise.getTenantId() == null) {
enterprise.setTenantId(getTenantId());
}
hjcEnterpriseService.save(enterprise);
} else {
enterprise.setId(exist.getId());
enterprise.setUserId(userId);
enterprise.setAuthStatus(0);
hjcEnterpriseService.updateById(enterprise);
}
Integer enterpriseId = isNew ? enterprise.getId() : exist.getId();
// 替换证件材料:先删旧再插新
hjcEnterpriseMaterialService.remove(new LambdaQueryWrapper<HjcEnterpriseMaterial>()
.eq(HjcEnterpriseMaterial::getEnterpriseId, enterpriseId));
List<HjcEnterpriseMaterial> materials = enterprise.getMaterials();
if (materials != null) {
for (HjcEnterpriseMaterial m : materials) {
if (StrUtil.isBlank(m.getFileUrl())) {
continue;
}
m.setId(null);
m.setEnterpriseId(enterpriseId);
if (m.getTenantId() == null) {
m.setTenantId(getTenantId());
}
hjcEnterpriseMaterialService.save(m);
}
}
return success("提交成功,等待审核", enterpriseId);
}
@Operation(summary = "后台-企业资质分页")
@GetMapping("/page")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<PageResult<HjcEnterprise>> page(HjcEnterpriseParam param) {
return success(hjcEnterpriseService.pageRel(param));
}
@Operation(summary = "后台-资质详情")
@GetMapping("/{id}")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> detail(@PathVariable("id") Integer id) {
HjcEnterprise enterprise = hjcEnterpriseService.getById(id);
if (enterprise == null) {
return fail("企业不存在");
}
enterprise.setMaterials(listMaterials(id));
return success(enterprise);
}
@Operation(summary = "后台-审核:通过/驳回")
@PutMapping("/auth")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> audit(@RequestBody HjcEnterprise param) {
if (param.getId() == null || param.getAuthStatus() == null) {
return fail("审核参数不完整");
}
HjcEnterprise enterprise = new HjcEnterprise();
enterprise.setId(param.getId());
enterprise.setAuthStatus(param.getAuthStatus());
enterprise.setRejectReason(param.getRejectReason());
hjcEnterpriseService.updateById(enterprise);
return success("审核完成");
}
private List<HjcEnterpriseMaterial> listMaterials(Integer enterpriseId) {
return hjcEnterpriseMaterialService.list(new LambdaQueryWrapper<HjcEnterpriseMaterial>()
.eq(HjcEnterpriseMaterial::getEnterpriseId, enterpriseId)
.orderByAsc(HjcEnterpriseMaterial::getId));
}
}
@@ -0,0 +1,41 @@
package com.gxwebsoft.hjc.controller;
import cn.hutool.core.util.StrUtil;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.hjc.dto.HjcOcrRecognizeRequest;
import com.gxwebsoft.hjc.dto.HjcOcrResult;
import com.gxwebsoft.hjc.service.HjcOcrService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* 汇吉采 证件 OCR 识别(注册页在登录前调用,SecurityConfig 已匿名放行)
*/
@Tag(name = "汇吉采-证件OCR")
@RestController
@RequestMapping("/api/hjc/ocr")
public class HjcOcrController extends BaseController {
@Resource
private HjcOcrService hjcOcrService;
@Operation(summary = "识别证件(身份证人像面/营业执照),回填表单字段")
@PostMapping("/recognize")
public ApiResult<HjcOcrResult> recognize(@RequestBody HjcOcrRecognizeRequest request) {
if (StrUtil.isBlank(request.getMaterialType()) || StrUtil.isBlank(request.getFileUrl())) {
return fail("materialType 与 fileUrl 不能为空", null);
}
HjcOcrResult result = hjcOcrService.recognize(request.getMaterialType(), request.getFileUrl());
if (result == null) {
return fail("识别失败或该类型无需识别,请手动填写", null);
}
return success(result);
}
}
@@ -0,0 +1,587 @@
package com.gxwebsoft.hjc.controller;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.dto.CreateOrderRequest;
import com.gxwebsoft.hjc.auth.HjcAdminGuard;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.hjc.auth.HjcAuthResponses;
import com.gxwebsoft.hjc.entity.HjcBidProject;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcOrder;
import com.gxwebsoft.hjc.param.HjcOrderParam;
import com.gxwebsoft.hjc.service.HjcBidProjectService;
import com.gxwebsoft.hjc.service.HjcBizService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import com.gxwebsoft.hjc.service.HjcOrderService;
import com.gxwebsoft.hjc.util.HjcOrderCancelUtil;
import com.gxwebsoft.hjc.util.HjcTenderFileUtil;
import com.gxwebsoft.payment.dto.PaymentRequest;
import com.gxwebsoft.payment.dto.PaymentResponse;
import com.gxwebsoft.payment.enums.PaymentStatus;
import com.gxwebsoft.payment.enums.PaymentType;
import com.gxwebsoft.payment.service.PaymentService;
import com.wechat.pay.java.core.exception.ServiceException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 汇吉采标书订单(下单 + 我的订单 + 后台订单)
*/
@Tag(name = "汇吉采-标书订单")
@Slf4j
@RestController
@RequestMapping("/api/hjc/order")
public class HjcOrderController extends BaseController {
private static final DateTimeFormatter ORDER_NO_FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
@Resource
private HjcOrderService hjcOrderService;
@Resource
private HjcBidProjectService hjcBidProjectService;
@Resource
private HjcEnterpriseService hjcEnterpriseService;
@Resource
private PaymentService paymentService;
@Resource
private HjcBizService hjcBizService;
@Resource
private HjcAdminGuard hjcGuard;
@Operation(summary = "下单(创建待支付订单)")
@PostMapping("/create")
public ApiResult<?> create(@RequestBody CreateOrderRequest request) {
Integer userId = getLoginUserId();
if (userId == null) {
return HjcAuthResponses.unauthorized();
}
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId);
if (enterprise == null) {
return fail("请先完善企业信息");
}
if (enterprise.getAuthStatus() == null || enterprise.getAuthStatus() != 1) {
return fail("企业资质未通过审核,暂无权限购买");
}
if (request.getProjectId() == null) {
return fail("标书项目ID不能为空");
}
HjcBidProject project = hjcBidProjectService.getById(request.getProjectId());
if (project == null) {
return fail("标书项目不存在");
}
if (project.getStatus() == null || project.getStatus() != 1) {
return fail("该项目未在售");
}
int quantity = request.getQuantity() == null || request.getQuantity() < 1 ? 1 : request.getQuantity();
BigDecimal unitPrice = project.getTenderPrice() == null ? BigDecimal.ZERO : project.getTenderPrice();
String orderNo = "HJC" + ORDER_NO_FMT.format(LocalDateTime.now()) + RandomUtil.randomNumbers(4);
HjcOrder order = new HjcOrder();
order.setOrderNo(orderNo);
order.setProjectId(project.getId());
order.setProjectNo(project.getProjectNo());
order.setProjectName(project.getProjectName());
order.setEnterpriseId(enterprise.getId());
order.setEnterpriseName(enterprise.getName());
order.setContactName(firstNotBlank(request.getContactName(), enterprise.getAgentName()));
order.setContactPhone(firstNotBlank(request.getContactPhone(), enterprise.getAgentPhone()));
order.setContactEmail(firstNotBlank(request.getContactEmail(), enterprise.getAgentEmail()));
order.setQuantity(quantity);
order.setUnitPrice(unitPrice);
order.setTotalAmount(unitPrice.multiply(BigDecimal.valueOf(quantity)));
order.setPayMethod("WECHAT_NATIVE");
order.setPayStatus(0);
order.setOrderStatus(0);
order.setInvoiceStatus(0);
order.setTenantId(enterprise.getTenantId());
order.setDeleted(0);
hjcOrderService.save(order);
// 购买数量累加
HjcBidProject up = new HjcBidProject();
up.setId(project.getId());
up.setSaleCount((project.getSaleCount() == null ? 0 : project.getSaleCount()) + quantity);
hjcBidProjectService.updateById(up);
return success("下单成功", order);
}
@Operation(summary = "发起支付(微信Native扫码,返回codeUrl)")
@PostMapping("/pay")
public ApiResult<?> pay(@RequestBody Map<String, Object> body) {
Integer userId = getLoginUserId();
if (userId == null) {
return HjcAuthResponses.unauthorized();
}
String orderNo = body.get("orderNo") == null ? null : String.valueOf(body.get("orderNo"));
if (orderNo == null) {
return fail("订单号不能为空");
}
String openid = body.get("openid") == null ? null : String.valueOf(body.get("openid"));
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
if (order == null) {
return fail("订单不存在");
}
if (order.getPayStatus() != null && order.getPayStatus() == 1) {
return fail("订单已支付");
}
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId);
if (enterprise == null || !enterprise.getId().equals(order.getEnterpriseId())) {
return fail("无权操作该订单");
}
PaymentRequest request = new PaymentRequest();
request.setTenantId(order.getTenantId());
request.setUserId(userId);
// 公众号内带 openid 走 JSAPI;否则回退 Native 扫码
request.setPaymentType(PaymentType.WECHAT);
request.setOpenId(openid);
request.setAmount(order.getTotalAmount());
request.setSubject(truncate(order.getProjectName(), 127));
request.setOrderNo(order.getOrderNo());
try {
PaymentResponse resp = paymentService.createPayment(request);
return success("发起支付成功", resp);
} catch (com.gxwebsoft.payment.exception.PaymentException e) {
// 带上**微信原始错误码**(如 OUT_TRADE_NO_USED / APPID_MCHID_NOT_MATCH),前端据此给可行动的中文提示。
// 为什么需要它:同一个订单号不能换支付类型重复下单——微信内(h5 → 小程序,JSAPI)与 PCNative
// 各发起一次时必现 OUT_TRADE_NO_USED,而"从订单列表回到旧单支付"这条路径正是本轮新开的。
Map<String, Object> data = new HashMap<>(2);
data.put("wechatCode", wechatCodeOf(e));
return fail("发起支付失败:" + e.getMessage(), data);
}
}
@Operation(summary = "买家取消自己的待支付订单(幂等;微信已收款则拒绝取消并自愈)")
@PostMapping("/cancel")
public ApiResult<?> cancel(@RequestBody Map<String, Object> body) {
Integer userId = getLoginUserId();
if (userId == null) {
return HjcAuthResponses.unauthorized();
}
String orderNo = body.get("orderNo") == null ? null : String.valueOf(body.get("orderNo"));
if (orderNo == null) {
return fail("订单号不能为空");
}
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
if (order == null) {
return fail("订单不存在");
}
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId);
if (enterprise == null || !enterprise.getId().equals(order.getEnterpriseId())) {
return HjcAuthResponses.forbidden("无权操作该订单");
}
// 第一判:本地状态就能定论时,不必也不应去打微信(已取消是幂等出口,一个后续动作都不做)
HjcOrderCancelUtil.Decision decision =
HjcOrderCancelUtil.decide(order.getPayStatus(), order.getOrderStatus(), null);
if (decision == HjcOrderCancelUtil.Decision.ALREADY_CANCELLED) {
return success("已取消", cancelOutcome(order, true, false, true));
}
if (decision == HjcOrderCancelUtil.Decision.ALREADY_PAID) {
return success("该订单已支付", cancelOutcome(order, false, true, true));
}
// 第二判:向微信查单。「其实已付款、只是本地还没落」只有这一步能挡住。
String wechatStatus = queryWechatStatusQuietly(order);
decision = HjcOrderCancelUtil.decide(order.getPayStatus(), order.getOrderStatus(), wechatStatus);
if (decision == HjcOrderCancelUtil.Decision.ALREADY_PAID) {
// 微信说收款了,先把本地补齐(幂等,且会触发一站式推送——这才是**正确**的推送时机)
if (HjcOrderCancelUtil.needsSelfHeal(order.getPayStatus(), wechatStatus)) {
try {
hjcBizService.markPaid(orderNo);
} catch (Exception e) {
// 补写失败不改变结论:本次取消仍必须被拒绝
log.warn("HjcOrder: 取消时发现微信已支付,补写本地状态失败 orderNo={}", orderNo, e);
}
}
HjcOrder latest = hjcOrderService.getByOrderNo(orderNo);
return success("该订单已支付",
cancelOutcome(latest == null ? order : latest, false, true, true));
}
// 事务内条件更新。失败一律按「取消失败」回复,不猜结果。
try {
hjcBizService.cancelUnpaidOrder(orderNo);
} catch (Exception e) {
log.error("HjcOrder: 取消订单失败 orderNo={}", orderNo, e);
return fail("取消失败,请稍后重试");
}
// 事务外尽力关微信单:让那张支付链接失效,避免"已取消却仍能付款"
closeWechatOrderQuietly(order);
// **以重新读到的状态为准**,而不是以条件更新的返回值或"我刚刚发了 UPDATE"为准:
// 条件更新影响 0 行可能是并发重复取消(此时已经是已取消,回复 cancelled=true 才对),
// 也可能是查单之后、更新之前有一笔支付落了库(此时应回复 paid=true)。
HjcOrder latest = hjcOrderService.getByOrderNo(orderNo);
HjcOrder current = latest == null ? order : latest;
boolean cancelled = HjcOrderCancelUtil.isCancelled(current.getOrderStatus());
boolean paid = HjcOrderCancelUtil.isLocallyPaid(current.getPayStatus());
if (!cancelled && !paid) {
// 既没取消也没支付(例如期间被后台退款):如实回报,不替它下结论
log.warn("HjcOrder: 取消未生效 orderNo={}, payStatus={}, orderStatus={}",
orderNo, current.getPayStatus(), current.getOrderStatus());
return fail("取消未生效,请刷新后查看订单状态");
}
return success(cancelled ? "已取消" : "该订单已支付",
cancelOutcome(current, cancelled, paid, HjcOrderCancelUtil.verified(wechatStatus)));
}
@Operation(summary = "标记订单已支付(支付成功后调用,幂等),并触发一站式推送")
@PutMapping("/mark-paid")
public ApiResult<?> markPaid(@RequestBody Map<String, Object> body) {
Integer userId = getLoginUserId();
if (userId == null) {
return HjcAuthResponses.unauthorized();
}
String orderNo = body.get("orderNo") == null ? null : String.valueOf(body.get("orderNo"));
if (orderNo == null) {
return fail("订单号不能为空");
}
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
if (order == null) {
return fail("订单不存在");
}
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId);
if (enterprise == null || !enterprise.getId().equals(order.getEnterpriseId())) {
return fail("无权操作该订单");
}
return success("已支付", hjcBizService.markPaid(orderNo));
}
@Operation(summary = "我的订单")
@GetMapping("/my")
public ApiResult<?> my() {
Integer userId = getLoginUserId();
if (userId == null) {
return HjcAuthResponses.unauthorized();
}
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId);
if (enterprise == null) {
return fail("企业不存在");
}
List<HjcOrder> list = hjcOrderService.list(new LambdaQueryWrapper<HjcOrder>()
.eq(HjcOrder::getEnterpriseId, enterprise.getId())
.orderByDesc(HjcOrder::getId));
// 已支付订单带上标书附件(其余订单 tenderFiles 保持 null)。整批一次查项目,不要逐单查。
attachTenderFiles(list);
return success(list);
}
@Operation(summary = "订单详情")
@GetMapping("/{id}")
public ApiResult<?> detail(@PathVariable("id") Integer id) {
// 先判身份、再判存在:顺序反了会让匿名调用方靠「订单不存在 / 未登录」的差异
// 枚举出哪些订单 ID 真实存在。
User loginUser = hjcGuard.currentUser();
if (loginUser == null) {
return HjcAuthResponses.unauthorized();
}
HjcOrder order = hjcOrderService.getById(id);
if (order == null) {
return fail("订单不存在");
}
// 归属校验:订单详情只对「订单所属企业的买家」或 hjc 管理员开放。
// 此前没有任何校验,且 GET 全放行,等于任何人(含匿名)都能按 id 遍历读取全部订单。
ApiResult<?> denied = checkReadable(loginUser, order);
if (denied != null) {
return denied;
}
HjcBidProject project = hjcBidProjectService.getById(order.getProjectId());
order.setProject(project);
// 顺序不能倒:先从 project.tenderFile 解析出附件(只有已支付订单会拿到),
// 再把项目上的原始字段抹掉——订单路径不提供第二条地址来源(见 HjcOrder#tenderFiles)。
attachTenderFiles(order);
if (project != null) {
project.setTenderFile(null);
}
return success(order);
}
@Operation(summary = "按订单号查订单(收银台只有订单号,用它取应付金额)")
@GetMapping("/by-no/{orderNo}")
public ApiResult<?> detailByOrderNo(@PathVariable("orderNo") String orderNo) {
// 与 detail 同序:先判身份、再判存在、最后判归属,避免新增一条能绕过归属校验的读取路径
User loginUser = hjcGuard.currentUser();
if (loginUser == null) {
return HjcAuthResponses.unauthorized();
}
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
if (order == null) {
return fail("订单不存在");
}
ApiResult<?> denied = checkReadable(loginUser, order);
if (denied != null) {
return denied;
}
// 收银台只用来展示应付金额,不回带 project(与 detail 的区别),少一次查询
return success(order);
}
/**
* 订单读取的归属校验:买家只能读本企业的订单,hjc 管理员放行。
*
* @return 不可读时返回错误响应;可读时返回 null
*/
private ApiResult<?> checkReadable(User loginUser, HjcOrder order) {
if (hjcGuard.isAdmin()) {
return null;
}
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(loginUser.getUserId());
if (enterprise == null || !enterprise.getId().equals(order.getEnterpriseId())) {
return HjcAuthResponses.forbidden("无权查看该订单");
}
return null;
}
/**
* 给**批量**订单附带标书附件(「我的订单」列表用)。
*
* <p>只有 {@code payStatus == 1}(已支付)的订单会拿到;其余订单的 {@code tenderFiles} 保持
* {@code null}——「没付费」与「付费了但项目没附件」(空列表)是两件事,前端据此区分
* 「不给下载」与「暂无附件」。</p>
*
* <p>一次性 {@code listByIds},不要逐单查:列表接口无分页,逐单查会变成 N+1。</p>
*/
private void attachTenderFiles(List<HjcOrder> orders) {
if (orders == null || orders.isEmpty()) {
return;
}
List<HjcOrder> paidOrders = new ArrayList<>();
List<Integer> projectIds = new ArrayList<>();
for (HjcOrder order : orders) {
if (!isPaid(order) || order.getProjectId() == null) {
continue;
}
paidOrders.add(order);
if (!projectIds.contains(order.getProjectId())) {
projectIds.add(order.getProjectId());
}
}
if (paidOrders.isEmpty()) {
return;
}
Map<Integer, String> tenderFileOfProject = new HashMap<>(projectIds.size());
for (HjcBidProject project : hjcBidProjectService.listByIds(projectIds)) {
if (project != null) {
tenderFileOfProject.put(project.getId(), project.getTenderFile());
}
}
for (HjcOrder order : paidOrders) {
order.setTenderFiles(HjcTenderFileUtil.parse(tenderFileOfProject.get(order.getProjectId())));
}
}
/**
* 给**单张**订单附带标书附件(订单详情用;项目对象已在手上,不再查库)。
*
* <p>调用方若还要把 {@code project.tenderFile} 抹掉,必须**先调本方法**再抹。</p>
*/
private void attachTenderFiles(HjcOrder order) {
if (order == null || !isPaid(order)) {
return;
}
HjcBidProject project = order.getProject();
if (project == null && order.getProjectId() != null) {
project = hjcBidProjectService.getById(order.getProjectId());
}
order.setTenderFiles(HjcTenderFileUtil.parse(project == null ? null : project.getTenderFile()));
}
/**
* 这张订单是否**已付款**(下载权限的唯一依据)。
*
* <p>刻意**不**复用「已完成」的展示口径({@code payStatus == 1 || orderStatus == 1}):
* 展示口径带兜底分支,而「能不能下载」必须绑在付款事实上。退款({@code payStatus == 3}
* 与待支付都不算。</p>
*/
private boolean isPaid(HjcOrder order) {
return order != null && order.getPayStatus() != null && order.getPayStatus() == 1;
}
@Operation(summary = "后台-订单分页")
@GetMapping("/page")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<PageResult<HjcOrder>> page(HjcOrderParam param) {
return success(hjcOrderService.pageRel(param));
}
@Operation(summary = "退款(标记已退款,记录退款时间/退款原因,幂等),并推送 REFUNDED 状态")
@PostMapping("/refund")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> refund(@RequestBody Map<String, Object> body) {
String orderNo = body.get("orderNo") == null ? null : String.valueOf(body.get("orderNo"));
if (orderNo == null) {
return fail("订单号不能为空");
}
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
if (order == null) {
return fail("订单不存在");
}
if (order.getPayStatus() != null && order.getPayStatus() == 3) {
return success("已是退款状态", order);
}
// 退款原因随 REFUNDED 状态推送给一站式(必带字段),为空时由服务端兜底默认原因
String refundReason = body.get("refundReason") == null ? null : String.valueOf(body.get("refundReason"));
return success("已退款", hjcBizService.refund(orderNo, refundReason));
}
/**
* 向微信查单,返回**微信侧**的真实支付状态;若微信已确认支付成功,则**顺带修复本地订单状态**。
*
* <p><b>为什么需要这个接口</b>:客户端在 JSAPI/requestPayment 失败后只能看到一句错误
* (用户取消、密码错、还是其实已经付成功了,前端分不清)。而「已支付」是事实,
* 应当以微信侧为准去查,而不是由前端调 {@code /mark-paid} 自说自话。</p>
*
* <p><b>为什么这里要写库(而不只是查)</b>:汇吉采的支付结果回写<b>完全依赖前端</b>——
* 微信回调 {@code PaymentNotifyController} 只做验签与应答,不认识 hjc 订单;
* 订单置为已支付只有 {@code /mark-paid} 一条路。于是「用户付了钱但页面被关掉/断网」
* 就会留下一笔已收款却仍显示待支付的订单。这里是唯一以微信侧为准的入口,
* 因此在确认 {@code SUCCESS} 时就地调 {@code markPaid} 补齐状态(幂等,且会触发一站式推送)。</p>
*
* <p>安全性:查询前已做「必须登录 + 订单归属校验」,故不存在越权改他人订单状态的问题。</p>
*/
@Operation(summary = "查询微信侧支付状态;已支付则顺势修复本单状态")
@GetMapping("/pay-status/{orderNo}")
public ApiResult<?> payStatus(@PathVariable("orderNo") String orderNo) {
User loginUser = getLoginUser();
if (loginUser == null) {
return HjcAuthResponses.unauthorized();
}
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
if (order == null) {
return fail("订单不存在");
}
ApiResult<?> unreadable = checkReadable(loginUser, order);
if (unreadable != null) {
return unreadable;
}
try {
PaymentResponse resp = paymentService.queryPayment(
orderNo, PaymentType.WECHAT, order.getTenantId());
String status = resp == null || resp.getPaymentStatus() == null
? null : resp.getPaymentStatus().name();
boolean repaired = false;
// 微信说成功了,本地还是未支付 —— 说明前端那次 mark-paid 没送达,这里补齐
if (PaymentStatus.SUCCESS.name().equals(status)
&& (order.getPayStatus() == null || order.getPayStatus() != 1)) {
try {
hjcBizService.markPaid(orderNo);
repaired = true;
log.info("HjcOrder: 查单发现微信已支付,已补写本地订单状态 orderNo={}", orderNo);
} catch (Exception e) {
// 补写失败不影响本次查询结论:前端仍会看到 SUCCESS
log.warn("HjcOrder: 补写本地订单状态失败 orderNo={}", orderNo, e);
}
}
Map<String, Object> data = new HashMap<>(6);
data.put("orderNo", orderNo);
data.put("paymentStatus", status);
data.put("transactionId", resp == null ? null : resp.getTransactionId());
data.put("repaired", repaired);
return success("查询成功", data);
} catch (com.gxwebsoft.payment.exception.PaymentException e) {
return fail("查询支付状态失败:" + e.getMessage());
}
}
/**
* 向微信查单,取状态名;**查不到结论时返回 null**(网络/配置/业务失败都算"没核对上"),不抛异常。
*
* <p><b>为什么查单失败也放行取消</b>:微信侧是唯一权威(ADR 0009),但权威**不可达**时如果拒绝取消,
* 这个功能在商户配置修好之前等于不存在(租户 10626 的商户号本来就没绑对小程序 appId)。
* 代价是「其实已付款、只是本地没落」的单会被标成已取消——所以响应里带 {@code verified=false}
* 把不确定性如实告诉用户,而不是替他下结论。</p>
*/
private String queryWechatStatusQuietly(HjcOrder order) {
try {
PaymentResponse resp = paymentService.queryPayment(
order.getOrderNo(), PaymentType.WECHAT, order.getTenantId());
return resp == null || resp.getPaymentStatus() == null ? null : resp.getPaymentStatus().name();
} catch (Exception e) {
log.warn("HjcOrder: 取消前查单失败,本次按「未与微信核对」处理 orderNo={}", order.getOrderNo(), e);
return null;
}
}
/**
* 尽力关闭微信侧订单(让那张支付链接失效,避免"已取消却仍能付款")。
*
* <p><b>失败只记日志、不回滚取消</b>:微信单可能已处于不可关状态(已付款 / 已过期),
* 因外部系统抖动把用户卡在他自己无法解决的失败态上,是更差的结果。</p>
*
* <p>也**不做**"是否发起过支付"的前置判断:本地没有可靠依据,而"微信侧未下单"与"关单失败"
* 在错误码上难以区分,不值得为省一次调用引入一个会猜错的判断。</p>
*/
private void closeWechatOrderQuietly(HjcOrder order) {
try {
paymentService.closeOrder(order.getOrderNo(), PaymentType.WECHAT, order.getTenantId());
} catch (Exception e) {
log.warn("HjcOrder: 取消订单后关闭微信支付单失败(不影响取消结果)orderNo={}", order.getOrderNo(), e);
}
}
/** 取消接口的结构化返回体(前端按字段判定,不解析 message) */
private Map<String, Object> cancelOutcome(HjcOrder order, boolean cancelled, boolean paid, boolean verified) {
Map<String, Object> data = new HashMap<>(8);
data.put("orderNo", order.getOrderNo());
data.put("cancelled", cancelled);
data.put("paid", paid);
data.put("verified", verified);
data.put("payStatus", order.getPayStatus());
data.put("orderStatus", order.getOrderStatus());
return data;
}
/**
* 从支付异常里取**微信原始错误码**(如 {@code OUT_TRADE_NO_USED})。
*
* <p>支付层把微信 SDK 的 {@code ServiceException} 原样挂在 cause 上——
* {@code WechatNativeStrategy.java:467} 与 {@code WechatJsapiStrategy.java:115} 都把它传给了
* 带 cause 的 {@code PaymentException} 构造器,而中途 {@code WechatNativeStrategy.java:141/:174}、
* {@code PaymentServiceImpl.java:111/:182} 全是 {@code throw e}(原对象,未重新包装)。
* 所以顺着 cause 链取即可:**不必**去 parse 异常 message 里的 JSON,也**不必**改动公共支付层
* AGENTS.md 要求改动收敛在 hjc 包内)。仓库内已有同款先例:
* {@code shop/service/impl/ShopOrderServiceImpl.java:414-419}。</p>
*
* @return 取不到时返回 null(如 {@code paramError} 这类没有 cause 的异常)
*/
private String wechatCodeOf(Throwable e) {
Throwable cause = e;
while (cause != null) {
if (cause instanceof ServiceException) {
return ((ServiceException) cause).getErrorCode();
}
cause = cause.getCause();
}
return null;
}
private String firstNotBlank(String a, String b) {
return a != null && !a.trim().isEmpty() ? a : b;
}
private String truncate(String s, int max) {
if (s == null) {
return "";
}
return s.length() > max ? s.substring(0, max) : s;
}
}
@@ -0,0 +1,427 @@
package com.gxwebsoft.hjc.controller;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.hjc.auth.HjcAuthProperties;
import com.gxwebsoft.hjc.auth.HjcAuthResponses;
import com.gxwebsoft.hjc.auth.HjcCoreAuthClient;
import com.gxwebsoft.hjc.dto.HjcPasswordApplyRequest;
import com.gxwebsoft.hjc.dto.HjcPasswordChangeRequest;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial;
import com.gxwebsoft.hjc.entity.HjcPasswordApply;
import com.gxwebsoft.hjc.param.HjcPasswordApplyParam;
import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import com.gxwebsoft.hjc.service.HjcPasswordApplyService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 汇吉采 密码找回(材料审核制)与修改密码。
*
* <p><b>为什么找回密码不是一个自助改密流程</b>:账号凭据归核心实例所有(ADR-0006),而 hjc 对核心实例
* 只有读的能力、没有任何合法的代改密码通道。因此:</p>
* <ul>
* <li><b>找回密码</b>=企业提交材料 → 平台人工审核 → <b>运维在核心实例管理后台照申请单执行重置</b>
* → 回本后台标记「已重置」。故「审核通过」与「已重置」是两个状态,界面上不得混为一谈。</li>
* <li><b>修改密码</b>=登录态下「旧密码 + 账号绑定手机号短信」双因子,两个因子都通过核心实例的接口完成。</li>
* </ul>
*
* <p>见 ADR-0008 与 {@code .scratch/hjc-password/spec.md}。</p>
*/
@Tag(name = "汇吉采-密码找回与修改密码")
@Slf4j
@RestController
@RequestMapping("/api/hjc")
public class HjcPasswordController extends BaseController {
/**
* 申请与进度查询的统一话术。
*
* <p><b>提交接口对「企业不存在 / 双要素不匹配 / 命中多条 / 已有待审核申请」一律返回这一句</b>,
* 响应体完全一致(连 data 都为 null),使外部无法据此判断某个企业是否在本平台注册过
* (见 spec 的「已定决策」第 2、8 条)。真正的失败原因只落服务端日志。</p>
*/
private static final String MSG_APPLY_ACCEPTED =
"申请已提交,请等待平台审核。审核期间可用企业名称与纳税人识别号在本页查询进度;若长时间无进展,请核对所填信息是否与营业执照一致。";
/** 与核心实例 {@code resetPassword} 的后端校验保持一致(前端也按同一规则提示) */
private static final Pattern PASSWORD_PATTERN =
Pattern.compile("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d@$!%*#?&]{8,}$");
/** 旧密码校验失败时可直接透传的文案(原文来自核心实例,见 core MainController#updatePassword */
private static final List<String> PASSTHROUGH_OLD_PASSWORD_MESSAGES =
Arrays.asList("原密码输入不正确", "请输入当前密码");
/** 同一 IP 每小时最多提交几次申请 */
private static final int APPLY_IP_LIMIT_PER_HOUR = 5;
/** 同一 IP 每小时最多查询几次进度 */
private static final int QUERY_IP_LIMIT_PER_HOUR = 30;
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Resource
private HjcPasswordApplyService passwordApplyService;
@Resource
private HjcEnterpriseService hjcEnterpriseService;
@Resource
private HjcEnterpriseMaterialService hjcEnterpriseMaterialService;
@Resource
private HjcAuthProperties hjcAuthProperties;
@Resource
private HjcCoreAuthClient coreAuthClient;
@Resource
private HttpServletRequest request;
// ==================== C 端:密码找回(匿名) ====================
@Operation(summary = "密码找回-提交申请(匿名:企业名称+纳税人识别号+新密码+授权委托书)")
@PostMapping("/auth/password/apply")
public ApiResult<?> apply(@RequestBody HjcPasswordApplyRequest body) {
if (body == null) {
return fail("参数不能为空");
}
String enterpriseName = StrUtil.trimToNull(body.getEnterpriseName());
String creditCode = StrUtil.trimToNull(body.getCreditCode());
if (enterpriseName == null) {
return fail("企业名称不能为空");
}
if (creditCode == null) {
return fail("纳税人识别号不能为空");
}
if (StrUtil.isBlank(body.getNewPassword()) || StrUtil.isBlank(body.getConfirmPassword())) {
return fail("请填写新密码并再次确认");
}
if (!body.getNewPassword().equals(body.getConfirmPassword())) {
return fail("两次输入的新密码不一致");
}
if (!PASSWORD_PATTERN.matcher(body.getNewPassword()).matches()) {
return fail("密码必须至少8位,且包含字母和数字");
}
if (StrUtil.isBlank(body.getHandbookUrl())) {
return fail("请上传授权委托书");
}
// 校验通过后才占用配额:否则一个填错字段的请求也会消耗用户的提交次数
if (!passwordApplyService.tryAcquireQuota("apply-ip", clientIp(), APPLY_IP_LIMIT_PER_HOUR, 3600L)) {
// 这一条可以明确报错:它只说明「你这个地址提交太频繁」,不泄露任何企业的注册状态
return fail("提交过于频繁,请稍后再试");
}
Integer tenantId = hjcAuthProperties.getTenantId();
List<HjcEnterprise> matched = hjcEnterpriseService.listByNameAndCreditCode(enterpriseName, creditCode, tenantId);
if (matched.isEmpty()) {
log.warn("HjcPassword: 找回申请的双要素未匹配到企业,已按统一话术响应 tenantId={}", tenantId);
return success(MSG_APPLY_ACCEPTED, null);
}
if (matched.size() > 1) {
// name 没有唯一索引,命中多条属脏数据。绝不能静默取一条——那会把 A 企业的密码重置落到 B 账号上。
log.error("HjcPassword: 双要素命中多条企业档案,拒绝受理 tenantId={} 命中数={} ids={}",
tenantId, matched.size(), idsOf(matched));
return success(MSG_APPLY_ACCEPTED, null);
}
HjcEnterprise enterprise = matched.get(0);
if (enterprise.getUserId() == null) {
log.error("HjcPassword: 企业档案未关联核心实例账号,拒绝受理 enterpriseId={}", enterprise.getId());
return success(MSG_APPLY_ACCEPTED, null);
}
HjcPasswordApply pending = passwordApplyService.getPendingByEnterpriseId(enterprise.getId(), tenantId);
if (pending != null) {
// 重复提交:不新建,直接让它去查进度(不返回「已存在」以免泄露注册状态)
log.info("HjcPassword: 该企业已有待审核申请,不重复新建 enterpriseId={} applyId={}",
enterprise.getId(), pending.getId());
return success(MSG_APPLY_ACCEPTED, null);
}
try {
passwordApplyService.submit(enterprise, body.getNewPassword(), body.getHandbookUrl(), tenantId);
} catch (Exception e) {
log.error("HjcPassword: 找回申请落库失败 enterpriseId={}", enterprise.getId(), e);
return fail("提交失败,请稍后重试");
}
log.info("HjcPassword: 找回申请已提交 enterpriseId={} userId={}", enterprise.getId(), enterprise.getUserId());
return success(MSG_APPLY_ACCEPTED, null);
}
@Operation(summary = "密码找回-查询进度(匿名:企业名称+纳税人识别号)")
@GetMapping("/auth/password/apply/status")
public ApiResult<?> applyStatus(@RequestParam(value = "enterpriseName", required = false) String enterpriseName,
@RequestParam(value = "creditCode", required = false) String creditCode) {
String name = StrUtil.trimToNull(enterpriseName);
String code = StrUtil.trimToNull(creditCode);
if (name == null || code == null) {
return fail("请填写企业名称与纳税人识别号");
}
if (!passwordApplyService.tryAcquireQuota("query-ip", clientIp(), QUERY_IP_LIMIT_PER_HOUR, 3600L)) {
return fail("查询过于频繁,请稍后再试");
}
Integer tenantId = hjcAuthProperties.getTenantId();
List<HjcEnterprise> matched = hjcEnterpriseService.listByNameAndCreditCode(name, code, tenantId);
if (matched.size() != 1) {
// 查不到与命中多条:一律回「无记录」,同样不区分企业是否存在。
// 不用 success(null)BaseController 的 success 有多个重载,传 null 会编译歧义。
return success("未查询到申请记录", null);
}
HjcPasswordApply apply = passwordApplyService.getLatestByEnterpriseId(matched.get(0).getId(), tenantId);
return success(apply == null ? null : statusView(apply));
}
// ==================== C 端:修改密码(登录态) ====================
@Operation(summary = "修改密码-发送短信验证码(登录态;发到账号绑定手机号,前端不可指定号码)")
@PostMapping("/auth/password/sms")
public ApiResult<?> changePasswordSms() {
User loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserId() == null) {
return HjcAuthResponses.unauthorized();
}
// 必须用 getPhone()User.getMobile() 是脱敏后的展示值(155****2748),拿它发短信必然失败
String phone = StrUtil.trimToNull(loginUser.getPhone());
if (phone == null) {
return fail("账号未绑定手机号,请联系平台客服");
}
HjcCoreAuthClient.CoreResult result = coreAuthClient.sendSmsCaptcha(phone);
if (!result.isOk()) {
return fail(StrUtil.blankToDefault(result.getMessage(), "验证码发送失败"));
}
return success("验证码已发送", null);
}
/**
* 修改密码(旧密码 + 短信验证码)。
*
* <p><b>两步的顺序不能换</b>:短信验证码只有核心实例的 {@code /resetPassword} 会校验,而它
* <b>同时就把密码改了</b>。若先改密再验短信,短信因子就等于不存在。因此:先用
* {@code PUT /auth/password}(新旧密码传同一个值)把旧密码验掉,再让 {@code /resetPassword}
* 以短信为闸门完成改密。</p>
*/
@Operation(summary = "修改密码(登录态;旧密码 + 账号绑定手机号短信验证码)")
@PutMapping("/auth/password/change")
public ApiResult<?> changePassword(@RequestBody HjcPasswordChangeRequest body) {
User loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserId() == null) {
return HjcAuthResponses.unauthorized();
}
if (body == null) {
return fail("参数不能为空");
}
if (StrUtil.isBlank(body.getOldPassword()) || StrUtil.isBlank(body.getSmsCode())
|| StrUtil.isBlank(body.getNewPassword()) || StrUtil.isBlank(body.getConfirmPassword())) {
return fail("请填写旧密码、短信验证码与新密码");
}
if (!body.getNewPassword().equals(body.getConfirmPassword())) {
return fail("两次输入的新密码不一致");
}
if (!PASSWORD_PATTERN.matcher(body.getNewPassword()).matches()) {
return fail("密码必须至少8位,且包含字母和数字");
}
if (body.getNewPassword().equals(body.getOldPassword())) {
return fail("新密码不能与旧密码相同");
}
String phone = StrUtil.trimToNull(loginUser.getPhone());
if (phone == null) {
return fail("账号未绑定手机号,请联系平台客服");
}
String authorization = request.getHeader("Authorization");
if (StrUtil.isBlank(authorization)) {
return HjcAuthResponses.unauthorized();
}
// 第一步:校验旧密码。核心实例该接口按登录态识别用户,故必须带上买家自己的 token
// (这是 hjc 唯一一条不剥 Authorization 的 core 调用;它不碰短信/图形码,无 Redis 前缀问题)。
HjcCoreAuthClient.CoreResult oldCheck = coreAuthClient.verifyOldPassword(authorization, body.getOldPassword());
if (!oldCheck.isOk()) {
if (oldCheck.getCode() == Constants.UNAUTHORIZED_CODE) {
// 核心实例的 403 = 买家账号没有 sys:auth:password 权限。这是本功能的已知前置条件
// (运维需在核心实例后台给租户 10626 的 user 角色授权,见 ADR-0008),
// 不是用户输错了密码——所以绝不能复用「原密码输入不正确」那套文案去误导用户。
log.error("HjcPassword: 核心实例拒绝改密(403),买家账号缺少 sys:auth:password 权限 userId={}",
loginUser.getUserId());
return fail("修改密码暂不可用,请联系平台客服");
}
String message = oldCheck.getMessage();
if (message == null || !PASSTHROUGH_OLD_PASSWORD_MESSAGES.contains(message)) {
log.warn("HjcPassword: 旧密码校验失败已映射为通用文案,userId={} 原始 message={} error={} raw={}",
loginUser.getUserId(), message, oldCheck.getError(), oldCheck.getRaw());
message = "旧密码校验失败,请稍后重试";
}
return fail(message);
}
// 第二步:短信校验与最终改密。必须带 userId + tenantId 锁定单个账号——
// 不带 userId 时核心实例会按手机号跨租户批量重置,那会连带改掉同号在其它平台的账号。
HjcCoreAuthClient.CoreResult reset = coreAuthClient.resetPassword(
loginUser.getUserId(),
hjcAuthProperties.getTenantId(),
phone,
StrUtil.trim(body.getSmsCode()),
body.getNewPassword(),
body.getConfirmPassword());
if (!reset.isOk()) {
log.warn("HjcPassword: 改密被核心实例拒绝 userId={} message={} error={} raw={}",
loginUser.getUserId(), reset.getMessage(), reset.getError(), reset.getRaw());
return fail(StrUtil.blankToDefault(reset.getMessage(), "修改密码失败,请稍后重试"));
}
return success("密码修改成功", null);
}
// ==================== 后台:密码找回审核 ====================
@Operation(summary = "后台-密码找回申请分页(列表不含明文新密码)")
@GetMapping("/password-apply/page")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<PageResult<HjcPasswordApply>> page(HjcPasswordApplyParam param) {
PageResult<HjcPasswordApply> result = passwordApplyService.pageRel(param);
if (result.getList() != null) {
// 明文新密码只在详情里出现,且详情受同一个管理员守卫保护
result.getList().forEach(item -> item.setNewPassword(null));
}
return success(result);
}
@Operation(summary = "后台-密码找回申请详情(含明文新密码与档案里旧的授权委托书地址)")
@GetMapping("/password-apply/{id}")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> detail(@PathVariable("id") Integer id) {
HjcPasswordApply apply = passwordApplyService.getById(id);
if (apply == null) {
return fail("申请不存在");
}
Map<String, Object> data = new LinkedHashMap<>(4);
data.put("apply", apply);
// 并列展示资质档案里那份旧委托书,供审核人对比新旧授权(本次必须重新上传)
data.put("archiveHandbookUrl", archiveHandbookUrl(apply.getEnterpriseId()));
return success(data);
}
@OperationLog(module = "汇吉采-密码找回", value = "审核密码找回申请")
@Operation(summary = "后台-审核:通过(待重置)/ 驳回(附原因)")
@PutMapping("/password-apply/audit")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> audit(@RequestBody HjcPasswordApply param) {
if (param == null) {
return fail("参数不能为空");
}
String error = passwordApplyService.audit(
param.getId(), param.getStatus(), param.getRejectReason(), getLoginUserId());
if (error != null) {
return fail(error);
}
return success("审核完成。通过后请在核心实例管理后台执行密码重置,再回来标记「已重置」。");
}
@OperationLog(module = "汇吉采-密码找回", value = "标记密码已重置")
@Operation(summary = "后台-标记已重置(仅在运维于核心实例执行完毕后点)")
@PutMapping("/password-apply/mark-reset")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> markReset(@RequestBody HjcPasswordApply param) {
if (param == null || param.getId() == null) {
return fail("参数不完整");
}
String error = passwordApplyService.markReset(param.getId(), getLoginUserId());
if (error != null) {
return fail(error);
}
return success("已标记为已重置", null);
}
// ==================== 内部工具 ====================
/** 进度的对外视图:**不含明文新密码**,只给状态与时间 */
private Map<String, Object> statusView(HjcPasswordApply apply) {
Map<String, Object> view = new LinkedHashMap<>(8);
view.put("status", apply.getStatus());
view.put("statusText", statusText(apply.getStatus()));
view.put("applyTime", format(apply.getCreateTime()));
view.put("auditTime", format(apply.getAuditTime()));
view.put("resetTime", format(apply.getResetTime()));
view.put("rejectReason", apply.getRejectReason());
return view;
}
private String archiveHandbookUrl(Integer enterpriseId) {
if (enterpriseId == null) {
return null;
}
HjcEnterpriseMaterial material = hjcEnterpriseMaterialService.getOne(
new LambdaQueryWrapper<HjcEnterpriseMaterial>()
.eq(HjcEnterpriseMaterial::getEnterpriseId, enterpriseId)
.eq(HjcEnterpriseMaterial::getMaterialType, "handbook")
.orderByDesc(HjcEnterpriseMaterial::getId)
.last("LIMIT 1"), false);
return material == null ? null : material.getFileUrl();
}
/**
* 取客户端 IP(用于频次限制)。
*
* <p>优先 {@code X-Forwarded-For} 的第一段:mp-java 部署在 nginx/网关之后,{@code getRemoteAddr()}
* 拿到的是反代地址,用它做限流会把所有人算成同一个主体。</p>
*/
private String clientIp() {
String forwarded = request.getHeader("X-Forwarded-For");
if (StrUtil.isNotBlank(forwarded) && !"unknown".equalsIgnoreCase(forwarded)) {
int comma = forwarded.indexOf(',');
return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim();
}
String realIp = request.getHeader("X-Real-IP");
if (StrUtil.isNotBlank(realIp) && !"unknown".equalsIgnoreCase(realIp)) {
return realIp.trim();
}
return request.getRemoteAddr();
}
private static String format(LocalDateTime time) {
return time == null ? null : TIME_FORMATTER.format(time);
}
private static String statusText(Integer status) {
if (status == null) {
return null;
}
switch (status) {
case HjcPasswordApply.STATUS_PENDING:
return "待审核";
case HjcPasswordApply.STATUS_APPROVED:
// 注意措辞:通过 ≠ 已重置,这里必须让用户知道还差平台侧一步
return "已通过,等待平台执行重置";
case HjcPasswordApply.STATUS_REJECTED:
return "已驳回";
case HjcPasswordApply.STATUS_RESET:
return "已重置,可用新密码登录";
default:
return "未知";
}
}
private static String idsOf(List<HjcEnterprise> list) {
StringBuilder sb = new StringBuilder();
for (HjcEnterprise item : list) {
if (sb.length() > 0) {
sb.append(',');
}
sb.append(item.getId());
}
return sb.toString();
}
}
@@ -0,0 +1,124 @@
package com.gxwebsoft.hjc.controller;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.hjc.auth.HjcAuthResponses;
import com.gxwebsoft.hjc.dto.HjcFavoriteRequest;
import com.gxwebsoft.hjc.entity.HjcBidProject;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.param.HjcProjectFavoriteParam;
import com.gxwebsoft.hjc.service.HjcBidProjectService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import com.gxwebsoft.hjc.service.HjcProjectFavoriteService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;
/**
* 汇吉采项目收藏(买家「我的收藏」)
*
* <p><b>为什么不校验企业资质</b>:收藏是零成本留资动作,资质只卡「下单购买」。
* 未认证 / 待审核 / 已驳回的企业都能收藏(spec 决策 3)。
* <b>不要</b>在这里顺手加 {@code authStatus == 1} 的判定。</p>
*
* <p><b>归属</b>:收藏挂在企业账号上({@code hjc_enterprise.id}),不挂自然人,
* 全企业共享一份(ADR 0011)。所以每个接口都要先
* {@code getLoginUserId() → hjcEnterpriseService.getByUserId(...)}。</p>
*
* <p><b>为什么每个方法都自己判登录</b>:共享 {@code SecurityConfig} 放行了 {@code GET /**}
* 未登录的 GET 会直接进到 controller(见 {@code HjcAuthResponses} 的类注释)。</p>
*
* @author WebSoft
*/
@Tag(name = "汇吉采-项目收藏")
@Slf4j
@RestController
@RequestMapping("/api/hjc/project-favorite")
public class HjcProjectFavoriteController extends BaseController {
@Resource
private HjcProjectFavoriteService hjcProjectFavoriteService;
@Resource
private HjcBidProjectService hjcBidProjectService;
@Resource
private HjcEnterpriseService hjcEnterpriseService;
@Operation(summary = "收藏项目(幂等)")
@PostMapping()
public ApiResult<?> add(@RequestBody HjcFavoriteRequest request) {
Integer userId = getLoginUserId();
if (userId == null) {
return HjcAuthResponses.unauthorized();
}
if (request == null || request.getProjectId() == null) {
return fail("标书项目ID不能为空");
}
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId);
if (enterprise == null) {
return fail("请先完善企业信息");
}
Integer projectId = request.getProjectId();
// 只判项目存在:**不判 status、不判 needSell** —— 已下架的项目也可以先收藏着
// (收藏不是购买,不该复刻购买的门槛)
HjcBidProject project = hjcBidProjectService.getById(projectId);
if (project == null) {
return fail("标书项目不存在");
}
// 幂等:已收藏时 service 内部直接返回,不改 create_timespec 决策 16
hjcProjectFavoriteService.add(enterprise.getId(), projectId, enterprise.getTenantId());
return success(favoriteOutcome(projectId, true));
}
@Operation(summary = "取消收藏(幂等)")
@DeleteMapping("/{projectId}")
public ApiResult<?> remove(@PathVariable("projectId") Integer projectId) {
Integer userId = getLoginUserId();
if (userId == null) {
return HjcAuthResponses.unauthorized();
}
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId);
if (enterprise == null) {
return fail("请先完善企业信息");
}
if (projectId == null) {
return fail("标书项目ID不能为空");
}
// 忽略影响行数:本来就没收藏也算成功(幂等)
hjcProjectFavoriteService.remove(enterprise.getId(), projectId);
return success(favoriteOutcome(projectId, false));
}
@Operation(summary = "我的收藏")
@GetMapping("/page")
public ApiResult<?> page(HjcProjectFavoriteParam param) {
Integer userId = getLoginUserId();
if (userId == null) {
return HjcAuthResponses.unauthorized();
}
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId);
if (enterprise == null) {
return fail("请先完善企业信息");
}
// **强制覆盖**enterpriseId 可以被 query string 绑定,不覆盖就是一个「读别家收藏」的入口。
// 同时收藏列表不支持客户端排序,这也是 PageParam 里要把 orders 清掉的原因。
param.setEnterpriseId(enterprise.getId());
return success(hjcProjectFavoriteService.pageRel(param));
}
/**
* 写接口的结构化返回体:前端按字段判定,不解析 message。
*
* <p>恒 {@code code = 0}(幂等契约,spec 决策 16):重复收藏、重复取消都是成功。</p>
*/
private Map<String, Object> favoriteOutcome(Integer projectId, boolean favorited) {
Map<String, Object> data = new HashMap<>(4);
data.put("projectId", projectId);
data.put("favorited", favorited);
return data;
}
}
@@ -0,0 +1,63 @@
package com.gxwebsoft.hjc.controller;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.hjc.dto.HjcOneStopProjectPush;
import com.gxwebsoft.hjc.entity.HjcOrder;
import com.gxwebsoft.hjc.service.HjcBizService;
import com.gxwebsoft.hjc.service.HjcOrderService;
import com.gxwebsoft.hjc.util.HjcOneStopAuthUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
/**
* 汇吉采一站式平台双向对接
*
* 入向:POST /api/hjc/push/project —— 一站式推送标书/中标公告,对称鉴权
* 出向:手动/定时触发 createPurchaseDetails 推送订单
*/
@Tag(name = "汇吉采-一站式对接")
@RestController
@RequestMapping("/api/hjc/push")
public class HjcPushController extends BaseController {
@Resource
private HjcBizService hjcBizService;
@Resource
private HjcOrderService hjcOrderService;
@Operation(summary = "入向:接收一站式推送的标书/中标公告")
@PostMapping("/project")
public ApiResult<?> receiveProject(@RequestHeader(value = "appKey", required = false) String appKey,
@RequestHeader(value = "timestamp", required = false) String timestamp,
@RequestHeader(value = "sign", required = false) String sign,
@RequestBody HjcOneStopProjectPush push) {
if (!HjcOneStopAuthUtil.verify(appKey, timestamp, sign)) {
return fail("鉴权失败");
}
if (push == null || push.getProjectNo() == null) {
return fail("项目编号不能为空");
}
Integer tenantId = getTenantId();
if (tenantId == null) {
return fail("租户ID不能为空");
}
return success("接收成功", hjcBizService.upsertFromPush(push, tenantId));
}
@Operation(summary = "出向:手动触发订单推送(createPurchaseDetails)")
@PostMapping("/order/{orderNo}")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> triggerPush(@PathVariable("orderNo") String orderNo) {
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
if (order == null) {
return fail("订单不存在");
}
hjcBizService.pushOrderToOneStop(order);
return success("已触发推送");
}
}
@@ -0,0 +1,480 @@
package com.gxwebsoft.hjc.controller;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson.JSONObject;
import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.system.entity.Payment;
import com.gxwebsoft.hjc.auth.HjcAuthProperties;
import com.gxwebsoft.hjc.util.HjcWechatReadinessUtil;
import com.gxwebsoft.payment.service.WxPayConfigService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import javax.sql.DataSource;
import java.io.IOException;
import java.net.URLEncoder;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 汇吉采微信对接:公众号 H5(网页授权取 openid、JS-SDK 签名)与小程序(code 换 openid)。
*
* <p>两套配置分别来自 Redis</p>
* <ul>
* <li>公众号:{@code cache{tenantId}:setting:wx-official} → {@code {"appId","appSecret"}}</li>
* <li>小程序:{@code mp-weixin:{tenantId}} → {@code {"appId","appSecret"}}(与平台通用小程序配置同一份)</li>
* </ul>
*
* <p><b>两套 openid 不可互用</b>:小程序 openid 属于小程序 appid,公众号网页授权 openid 属于
* 公众号 appid;微信支付 JSAPI 下单要求 openid 与商户号绑定的 appid 同源,故两条链路各自取各自的
* openid,服务端不做换算。</p>
*
* @author WebSoft
* @since 2026-09
*/
@Tag(name = "汇吉采-微信公众号")
@Slf4j
@RestController
@RequestMapping("/api/hjc/wechat")
public class HjcWechatController extends BaseController {
private static final String SETTING_KEY_PREFIX = "cache";
private static final String SETTING_KEY_SUFFIX = ":setting:wx-official";
private static final String ACCESS_TOKEN_KEY = "wx:jsapi:access_token:";
private static final String JSAPI_TICKET_KEY = "wx:jsapi:ticket:";
private static final long EXPIRE_SECONDS = 7000L;
/** 小程序配置的 Redis 键前缀(与平台通用小程序配置一致,见 RedisConstants.MP_WX_KEY */
private static final String MP_SETTING_KEY_PREFIX = "mp-weixin:";
/** 小程序在 sys_setting 里的 setting_key */
private static final String MP_WEIXIN_SETTING_KEY = "mp-weixin";
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private ConfigProperties configProperties;
@Resource
private HjcAuthProperties hjcAuthProperties;
/** 自检用:读取支付配置(跨库 sys_payment)以核对 appId 一致性 */
@Resource
private WxPayConfigService wxPayConfigService;
/** 用于回源读取跨库的 sys_setting(小程序配置在 Redis 缺失时的兜底) */
@Resource
private DataSource dataSource;
@Value("${spring.profiles.active:dev}")
private String activeProfile;
@Operation(summary = "微信接入自检(只读;管理员;排查「配置到底缺哪一项」)")
@GetMapping("/readiness")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> readiness(@RequestParam(value = "probe", required = false) Boolean probe) {
Integer tenantId = hjcAuthProperties.getTenantId();
List<Map<String, Object>> checks = new ArrayList<>();
boolean ok = true;
// 1) 小程序配置:mp-login 要用它换 openid
WxMpConfig mp = loadMpConfig();
ok &= HjcWechatReadinessUtil.addCheck(checks, "小程序 appId/appSecret", mp != null,
"小程序端 code 换 openidPOST /api/hjc/wechat/mp-login)依赖它;"
+ "缺失则小程序端拿不到 openid,支付流程第一步即失败",
mp == null ? "在后台「小程序配置」保存 appId + appSecret"
: "appId=" + HjcWechatReadinessUtil.mask(mp.appId));
// 2) 公众号配置:H5 的网页授权与 JS-SDK 签名(开放标签的 wx.config 也用它)
WxOfficialConfig off = loadConfig();
ok &= HjcWechatReadinessUtil.addCheck(checks, "公众号 appId/appSecret", off != null,
"H5 的网页授权与 JS-SDK 签名依赖它;缺失则 wx.config 失败,"
+ "开放标签不会渲染(收银台退回扫码,而微信内扫码不可用)",
off == null ? "配置 Redis 键 " + settingKey(tenantId)
: "appId=" + HjcWechatReadinessUtil.mask(off.appId));
// 3) 后端公网地址:网页授权回调域名要指到它
String serverUrl = configProperties.getServerUrl();
boolean serverUrlOk = HjcWechatReadinessUtil.notBlank(serverUrl);
ok &= HjcWechatReadinessUtil.addCheck(checks, "后端公网地址 serverUrl", serverUrlOk,
"网页授权回调 redirect_uri 由它拼出;为空则授权地址不可用",
serverUrlOk ? serverUrl : "配置 ConfigProperties.serverUrl");
// 4) 支付配置(跨库 gxwebsoft_core.sys_payment,走 Payment:1:{tenant} 缓存)
Payment pay = null;
String payErr = null;
try {
pay = wxPayConfigService.getPaymentConfigForStrategy(tenantId);
} catch (Exception e) {
payErr = e.getMessage();
}
boolean payOk = pay != null && HjcWechatReadinessUtil.notBlank(pay.getMchId())
&& HjcWechatReadinessUtil.notBlank(pay.getAppId());
ok &= HjcWechatReadinessUtil.addCheck(checks, "微信支付商户号/appId", payOk,
"JSAPI 下单依赖它;缺失则 /order/pay 直接失败",
payOk ? "mchId=" + HjcWechatReadinessUtil.mask(pay.getMchId()) + " appId=" + HjcWechatReadinessUtil.mask(pay.getAppId())
: (payErr != null ? payErr : "在后台配置微信支付"));
// 5) 关键一致性:支付 appId 必须与小程序 appId 相同。
// 微信要求「openid 与商户号绑定的 appid 同源」——本方案里 openid 来自小程序,
// 所以两者不同值必然导致 APPID_MCHID_NOT_MATCH / appid 与 openid 不匹配。
if (payOk && mp != null) {
boolean same = HjcWechatReadinessUtil.sameAppId(pay.getAppId(), mp.appId);
ok &= HjcWechatReadinessUtil.addCheck(checks, "支付 appId 与小程序 appId 一致", same,
"JSAPI 要求 openid 与商户号绑定的 appid 同源;不一致会在下单时被微信拒绝"
+ "APPID_MCHID_NOT_MATCH 或 openid 不匹配)",
"支付=" + HjcWechatReadinessUtil.mask(pay.getAppId()) + " 小程序=" + HjcWechatReadinessUtil.mask(mp.appId)
+ (same ? "" : " ← 需改成同一个 appId"));
}
// 6) 可选:真去微信取一次接口凭据/票据,验证 appSecret 有效且域名可达
if (Boolean.TRUE.equals(probe)) {
if (mp != null) {
ok &= HjcWechatReadinessUtil.addCheck(checks, "小程序 appSecret 有效性(实调微信)",
probeAccessToken(mp.appId, mp.appSecret),
"appSecret 错误或小程序未开通时,jscode2session 会失败",
"见日志");
}
if (off != null) {
ok &= HjcWechatReadinessUtil.addCheck(checks, "公众号 appSecret 有效性(实调微信)",
probeAccessToken(off.appId, off.appSecret),
"appSecret 错误时取不到 access_tokenJS-SDK 签名必然失败",
"见日志");
}
}
return success(ok ? "配置就绪" : "存在未就绪项,见 checks",
HjcWechatReadinessUtil.summary(tenantId, ok, checks));
}
/** 实调微信 client_credential,验证 appId/appSecret 有效(自检专用,失败只记日志) */
private boolean probeAccessToken(String appId, String appSecret) {
try {
String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid="
+ appId + "&secret=" + appSecret;
JSONObject json = JSONObject.parseObject(HttpUtil.get(url));
boolean ok = json != null && json.getString("access_token") != null;
if (!ok) {
log.warn("HjcWechat 自检: 取 access_token 失败 appId={} errcode={} errmsg={}",
HjcWechatReadinessUtil.mask(appId), json == null ? null : json.getString("errcode"),
json == null ? null : json.getString("errmsg"));
}
return ok;
} catch (Exception e) {
log.warn("HjcWechat 自检: 取 access_token 异常 appId={}", HjcWechatReadinessUtil.mask(appId), e);
return false;
}
}
private String settingKey(Integer tenantId) {
return SETTING_KEY_PREFIX + tenantId + SETTING_KEY_SUFFIX;
}
@Operation(summary = "获取公众号网页授权地址(静默 snsapi_base")
@GetMapping("/authorize")
public ApiResult<?> authorize(@RequestParam(value = "redirect", required = false) String redirect) {
WxOfficialConfig cfg = loadConfig();
if (cfg == null) {
return fail("公众号配置未找到,请先配置微信官方账号(appId/appSecret)");
}
try {
String callback = configProperties.getServerUrl() + "/api/hjc/wechat/oauth/callback";
String encodedCallback = URLEncoder.encode(callback, "UTF-8");
String state = redirect == null ? configProperties.getServerUrl() : redirect;
String url = "https://open.weixin.qq.com/connect/oauth2/authorize?appid=" + cfg.appId
+ "&redirect_uri=" + encodedCallback
+ "&response_type=code&scope=snsapi_base"
+ "&state=" + URLEncoder.encode(state, "UTF-8")
+ "#wechat_redirect";
Map<String, Object> data = new HashMap<>();
data.put("url", url);
return success("获取成功", data);
} catch (Exception e) {
return fail("获取授权地址失败:" + e.getMessage());
}
}
@Operation(summary = "公众号网页授权回调(code 换 openid 后回跳 H5")
@GetMapping("/oauth/callback")
public void oauthCallback(@RequestParam(value = "code", required = false) String code,
@RequestParam(value = "state", required = false) String state,
HttpServletResponse response) throws IOException {
WxOfficialConfig cfg = loadConfig();
if (cfg == null) {
response.sendRedirect(configProperties.getServerUrl());
return;
}
String target = state == null || state.isEmpty() ? configProperties.getServerUrl() : state;
try {
String url = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=" + cfg.appId
+ "&secret=" + cfg.appSecret + "&code=" + code + "&grant_type=authorization_code";
String respBody = HttpUtil.get(url);
JSONObject json = JSONObject.parseObject(respBody);
String openid = json.getString("openid");
if (openid != null && !openid.isEmpty()) {
target += (target.contains("?") ? "&" : "?") + "openid=" + openid;
}
} catch (Exception e) {
// 忽略,仍回跳
}
response.sendRedirect(target);
}
@Operation(summary = "获取小程序 appId(供 H5 渲染开放标签;不含任何密钥)")
@GetMapping("/mp-appid")
public ApiResult<?> mpAppId() {
WxMpConfig cfg = loadMpConfig();
if (cfg == null) {
// 指路到具体位置:这是运维第一次跑通时唯一能看到的线索。
// 注意 modules 库账号对 gxwebsoft_core.sys_setting 只有 SELECT 权限,
// 直连数据库写不进去,必须走后台接口保存(它写入该表)。
return fail("小程序配置未找到:请在管理后台「系统设置 → 小程序配置」保存 appId 与 appSecret");
}
Map<String, Object> data = new HashMap<>(2);
// 只回 appId:开放标签需要它,而它是公开信息;appSecret 绝不出后端
data.put("appId", cfg.appId);
return success("获取成功", data);
}
@Operation(summary = "小程序登录(code 换 openid")
@PostMapping("/mp-login")
public ApiResult<?> mpLogin(@RequestBody Map<String, String> body) {
String code = body == null ? null : body.get("code");
if (code == null || code.isEmpty()) {
return fail("code不能为空");
}
WxMpConfig cfg = loadMpConfig();
if (cfg == null) {
// 指路到具体位置:这是运维第一次跑通时唯一能看到的线索。
// 注意 modules 库账号对 gxwebsoft_core.sys_setting 只有 SELECT 权限,
// 直连数据库写不进去,必须走后台接口保存(它写入该表)。
return fail("小程序配置未找到:请在管理后台「系统设置 → 小程序配置」保存 appId 与 appSecret");
}
try {
// code 必须编码:它是 query 参数,含未编码的特殊字符时会把后面的参数截断/篡改
String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + cfg.appId
+ "&secret=" + cfg.appSecret + "&js_code=" + URLEncoder.encode(code, "UTF-8")
+ "&grant_type=authorization_code";
String respBody = HttpUtil.get(url);
JSONObject json = JSONObject.parseObject(respBody);
String openid = json.getString("openid");
if (openid == null || openid.isEmpty()) {
// errcode 40029=code 无效(常见于重复使用)、45011=频率限制、40163=code 已被使用
log.warn("HjcWechat: 小程序 code 换 openid 失败 errcode={} errmsg={}",
json.getString("errcode"), json.getString("errmsg"));
return fail("微信登录失败:" + json.getString("errmsg"));
}
Map<String, Object> data = new HashMap<>(4);
data.put("openid", openid);
// unionid 仅在开放平台账号绑定后才有;小程序与公众号若绑定了同一开放平台,
// 可用它把两端身份关联起来,这里透传但不作为必填
data.put("unionid", json.getString("unionid"));
return success("获取成功", data);
} catch (Exception e) {
log.warn("HjcWechat: 小程序登录异常", e);
return fail("微信登录失败:" + e.getMessage());
}
}
@Operation(summary = "生成微信 JS-SDK 签名(用于 wx.config")
@GetMapping("/jsapi-sign")
public ApiResult<?> jsapiSign(@RequestParam(value = "url", required = false) String url) {
WxOfficialConfig cfg = loadConfig();
if (cfg == null) {
return fail("公众号配置未找到");
}
if (url == null || url.isEmpty()) {
return fail("url参数不能为空");
}
try {
String accessToken = getAccessToken(cfg);
String jsapiTicket = getJsapiTicket(cfg, accessToken);
String nonceStr = RandomUtil.randomString(16);
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
String signatureStr = "jsapi_ticket=" + jsapiTicket +
"&noncestr=" + nonceStr +
"&timestamp=" + timestamp +
"&url=" + url;
String signature = SecureUtil.sha1(signatureStr);
Map<String, Object> data = new HashMap<>();
data.put("appId", cfg.appId);
data.put("timestamp", timestamp);
data.put("nonceStr", nonceStr);
data.put("signature", signature);
return success("获取成功", data);
} catch (Exception e) {
return fail("生成JS-SDK签名失败:" + e.getMessage());
}
}
private String getAccessToken(WxOfficialConfig cfg) {
String key = ACCESS_TOKEN_KEY + getTenantId();
String cached = stringRedisTemplate.opsForValue().get(key);
if (cached != null && !cached.isEmpty()) {
return cached;
}
String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + cfg.appId + "&secret=" + cfg.appSecret;
String respBody = HttpUtil.get(url);
JSONObject json = JSONObject.parseObject(respBody);
String token = json.getString("access_token");
if (token != null) {
stringRedisTemplate.opsForValue().set(key, token, EXPIRE_SECONDS, TimeUnit.SECONDS);
}
if (token == null) {
throw new IllegalStateException("获取微信access_token失败: " + json.getString("errmsg"));
}
return token;
}
private String getJsapiTicket(WxOfficialConfig cfg, String accessToken) {
String key = JSAPI_TICKET_KEY + getTenantId();
String cached = stringRedisTemplate.opsForValue().get(key);
if (cached != null && !cached.isEmpty()) {
return cached;
}
String url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=" + accessToken + "&type=jsapi";
String respBody = HttpUtil.get(url);
JSONObject json = JSONObject.parseObject(respBody);
String ticket = json.getString("ticket");
if (ticket != null) {
stringRedisTemplate.opsForValue().set(key, ticket, EXPIRE_SECONDS, TimeUnit.SECONDS);
}
if (ticket == null) {
throw new IllegalStateException("获取微信jsapi_ticket失败: " + json.getString("errmsg"));
}
return ticket;
}
private WxOfficialConfig loadConfig() {
Integer tenantId = getTenantId();
String key = SETTING_KEY_PREFIX + tenantId + SETTING_KEY_SUFFIX;
String raw = stringRedisTemplate.opsForValue().get(key);
if (raw == null || raw.isEmpty()) {
return null;
}
JSONObject json = JSONObject.parseObject(raw);
WxOfficialConfig cfg = new WxOfficialConfig();
cfg.appId = json.getString("appId");
cfg.appSecret = json.getString("appSecret");
if (cfg.appId == null || cfg.appSecret == null) {
return null;
}
return cfg;
}
/**
* 读取小程序配置。
*
* <p>用 {@link HjcAuthProperties} 的租户 ID,而不是请求头里的 tenantId:小程序登录发生在业务请求
* 之前,请求头里的租户是客户端可改的,而小程序 appId 只应对应汇吉采自己的那份配置。</p>
*
* <p><b>为什么要依次试三个来源</b>:这套工程里小程序配置存在三套互不一致的键,
* 只认其中任意一个都会出现「后台明明配了、接口却说没配」:</p>
* <ol>
* <li>{@code mp-weixin:{tenant}} —— {@code WxLoginController} 的读法;</li>
* <li>{@code setting:mp-weixin:{tenant}} —— {@code SettingController} 更新设置时
* <b>实际写入</b>的键(键名由 {@code "setting:" + key + ":" + tenant} 拼成);</li>
* <li>{@code gxwebsoft_core.sys_setting} —— 真正的落库位置。后台「批量保存」
* {@code POST /system/setting/batch}<b>只写库、完全不写缓存</b>,
* 所以不走这一步的话,用后台配好的小程序永远登录不了。</li>
* </ol>
*
* <p>跨库直读用原生 JDBC,既不依赖 common 层那份带坏 JOIN 的旧 mapper(见 ADR-0006),
* 也不受 MyBatis-Plus 多租户拦截器影响。</p>
*/
private WxMpConfig loadMpConfig() {
Integer tenantId = hjcAuthProperties.getTenantId();
String[] redisKeys = {
MP_SETTING_KEY_PREFIX + tenantId,
SETTING_KEY_PREFIX + ":" + MP_WEIXIN_SETTING_KEY + ":" + tenantId,
};
for (String key : redisKeys) {
// 缓存读失败不能当成「未配置」:Redis 一次抖动(实测见过 Connection reset
// 就会让 mp-appid/mp-login 整体失败,而数据库里本来就有一份可用配置。
// 这里吞掉缓存异常,继续往下走到数据库回源。
try {
WxMpConfig cfg = parseMpConfig(stringRedisTemplate.opsForValue().get(key));
if (cfg != null) {
return cfg;
}
} catch (Exception e) {
log.warn("HjcWechat: 读取小程序配置缓存失败,转为回源数据库 key={}", key, e);
}
}
return parseMpConfig(queryMpSettingFromDb(tenantId));
}
/** 解析小程序配置 JSON{@code {"appId":..,"appSecret":..}}),字段不全视为未配置 */
private WxMpConfig parseMpConfig(String raw) {
if (raw == null || raw.isEmpty()) {
return null;
}
try {
JSONObject json = JSONObject.parseObject(raw);
if (json == null) {
return null;
}
WxMpConfig cfg = new WxMpConfig();
cfg.appId = json.getString("appId");
cfg.appSecret = json.getString("appSecret");
if (cfg.appId == null || cfg.appSecret == null) {
return null;
}
return cfg;
} catch (Exception e) {
log.warn("HjcWechat: 小程序配置解析失败", e);
return null;
}
}
/**
* 跨库回源读 {@code gxwebsoft_core.sys_setting} 里的小程序配置。
*
* <p>只读、失败不抛(返回 null 让上层给「未配置」的提示),避免把配置读取的异常
* 变成小程序登录的整体不可用。</p>
*/
private String queryMpSettingFromDb(Integer tenantId) {
String sql = "SELECT content FROM gxwebsoft_core.sys_setting WHERE setting_key = ? AND tenant_id = ? LIMIT 1";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, MP_WEIXIN_SETTING_KEY);
ps.setInt(2, tenantId);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return rs.getString(1);
}
}
} catch (Exception e) {
log.warn("HjcWechat: 回源查询小程序配置失败 tenantId={}", tenantId, e);
}
return null;
}
private static class WxOfficialConfig {
String appId;
String appSecret;
}
private static class WxMpConfig {
String appId;
String appSecret;
}
}
@@ -0,0 +1,27 @@
package com.gxwebsoft.hjc.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 汇吉采下单请求
*/
@Data
@Schema(name = "CreateOrderRequest", description = "汇吉采下单请求")
public class CreateOrderRequest {
@Schema(description = "标书项目ID", required = true)
private Integer projectId;
@Schema(description = "购买数量", required = true)
private Integer quantity = 1;
@Schema(description = "购买联系人")
private String contactName;
@Schema(description = "购买联系电话")
private String contactPhone;
@Schema(description = "购买联系邮箱")
private String contactEmail;
}
@@ -0,0 +1,74 @@
package com.gxwebsoft.hjc.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
* 汇吉采 → 一站式平台:POST /api/biz/createPurchaseDetails 请求体(Q9 字段表)。
*/
@Data
@Schema(name = "CreatePurchaseDetails", description = "标书购买记录/订单推送体")
public class CreatePurchaseDetails {
@Schema(description = "幂等键")
private String idempotencyKey;
@Schema(description = "订单号")
private String orderNo;
@Schema(description = "项目编号")
private String projectNo;
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "标书单价(信息服务费)")
private BigDecimal tenderPrice;
@Schema(description = "购买数量")
private Integer quantity;
@Schema(description = "订单总额")
private BigDecimal totalAmount;
@Schema(description = "购买企业")
private Buyer buyer;
@Schema(description = "支付时间,yyyy-MM-dd HH:mm:ss")
private String paidAt;
@Schema(description = "支付方式:WECHAT_MP/ALIPAY")
private String payMethod;
@Schema(description = "状态:PAID/REFUNDED")
private String status;
@Schema(description = "退款时间,yyyy-MM-dd HH:mm:ss(仅 status=REFUNDED 时推送)")
@JsonInclude(JsonInclude.Include.NON_NULL)
private String refundTime;
@Schema(description = "退款原因(仅 status=REFUNDED 时推送)")
@JsonInclude(JsonInclude.Include.NON_NULL)
private String refundReason;
@Schema(description = "开票状态:NONE/APPLIED/ISSUED")
private String invoiceStatus;
@Data
@Schema(name = "CreatePurchaseDetails.Buyer", description = "购买企业信息")
public static class Buyer {
@Schema(description = "企业名称")
private String enterpriseName;
@Schema(description = "统一社会信用代码")
private String creditCode;
@Schema(description = "经办人姓名")
private String contactName;
@Schema(description = "经办人手机号")
private String contactPhone;
@Schema(description = "经办人邮箱")
private String contactEmail;
}
}
@@ -0,0 +1,64 @@
package com.gxwebsoft.hjc.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 汇吉采 网站企业 注册/登录 请求(企业名称+密码)
*/
@Data
@Schema(name = "HjcAuthRequest", description = "汇吉采企业注册/登录请求")
public class HjcAuthRequest {
@Schema(description = "企业名称(登录账号)", required = true)
private String enterpriseName;
@Schema(description = "登录密码", required = true)
private String password;
/**
* 验证码。两个接口各自含义不同,与核心实例的字段命名保持一致:
* <ul>
* <li>登录:<b>图形验证码</b>(来自 {@code GET /api/hjc/auth/captcha} 的图片)</li>
* <li>注册:<b>短信验证码</b>(发给「经办人手机号」,即账号手机号)</li>
* </ul>
*/
@Schema(description = "验证码:登录时为图形验证码,注册时为短信验证码")
private String code;
@Schema(description = "纳税人识别号/统一社会信用代码")
private String creditCode;
@Schema(description = "企业联系电话")
private String contactPhone;
@Schema(description = "企业邮箱")
private String contactEmail;
@Schema(description = "企业地址")
private String address;
@Schema(description = "经办人姓名")
private String agentName;
@Schema(description = "经办人邮箱")
private String agentEmail;
@Schema(description = "经办人手机号")
private String agentPhone;
@Schema(description = "经办人身份证号")
private String idCardNo;
@Schema(description = "授权委托书到期时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime authorizeExpire;
@Schema(description = "资质证件材料")
private List<HjcEnterpriseMaterial> materials;
}
@@ -0,0 +1,46 @@
package com.gxwebsoft.hjc.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 买家端轮播图(只读视图对象)
*
* <p>数据来自 CMS 的「轮播组 + 轮播项」(cms_banner_group / cms_banner_item),
* 已按「启用分组 + 未删除明细 + 生效时间窗口」过滤并扁平化,供 H5 首页直接渲染。
* 本对象只读,不对应任何 hjc 表。</p>
*/
@Data
@Schema(name = "HjcBannerVo", description = "买家端轮播图")
public class HjcBannerVo implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "轮播项ID")
private Integer itemId;
@Schema(description = "所属轮播组ID")
private Integer groupId;
@Schema(description = "标题(轮播项未填时取组标题)")
private String title;
@Schema(description = "副标题")
private String subtitle;
@Schema(description = "图片地址")
private String image;
@Schema(description = "跳转类型: 0无 1外链 2文章 3产品")
private Integer linkType;
@Schema(description = "跳转地址(外链)")
private String linkUrl;
@Schema(description = "跳转目标ID(文章/产品)")
private Integer linkTargetId;
@Schema(description = "排序(组内,越小越靠前)")
private Integer sortNumber;
}
@@ -0,0 +1,15 @@
package com.gxwebsoft.hjc.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 汇吉采收藏请求(只有一个字段:收藏哪个项目)
*/
@Data
@Schema(name = "HjcFavoriteRequest", description = "汇吉采收藏请求")
public class HjcFavoriteRequest {
@Schema(description = "标书项目ID", required = true)
private Integer projectId;
}
@@ -0,0 +1,86 @@
package com.gxwebsoft.hjc.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 我的收藏单条收藏行 + 项目的**当前值**实时不做收藏时快照
*
* <p>项目字段来自 {@code LEFT JOIN hjc_bid_project}项目被物理删除时它们全为 {@code null}
* 此时 {@link #joinedProjectId} {@code null}那是项目还在不在唯一可靠的判据
* 不要用 {@code projectName} 是否为 {@code null} 去猜</p>
*
* <p>三个判定用的原始字段{@code joinedProjectId} / {@code projectStatus} /
* {@code projectDeleted}标了 {@code @JsonIgnore}
* 展示态由 {@link #saleState} {@link #purchased} 表达原始字段**不属于接口契约**
* 泄漏出去就会有人拿它当契约用</p>
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(name = "HjcFavoriteVo对象", description = "我的收藏单条")
public class HjcFavoriteVo implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "收藏行ID")
private Integer id;
@Schema(description = "标书项目ID")
private Integer projectId;
@Schema(description = "收藏时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
// ---------- LEFT JOIN hjc_bid_project 得到的项目当前值 ----------
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "项目编号")
private String projectNo;
@Schema(description = "项目分类:服务类/工程类/货物类")
private String category;
@Schema(description = "标书价格/信息服务费")
private BigDecimal tenderPrice;
@Schema(description = "投标截止时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime deadlineTime;
@Schema(description = "停售时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime offsaleTime;
// ---------- 判定用的原始字段不进响应 ----------
/** 项目主键({@code b.id AS joined_project_id});为 null 即项目已被物理删除。 */
@JsonIgnore
private Integer joinedProjectId;
/** 项目状态:1 上架 / 0 下架({@code b.status AS project_status})。 */
@JsonIgnore
private Integer projectStatus;
/** 项目是否已逻辑删除({@code b.deleted AS project_deleted})。 */
@JsonIgnore
private Integer projectDeleted;
// ---------- 计算出来的展示态 ----------
@Schema(description = "售卖状态:onsale 在售 / ended 已结束 / removed 已下架")
private String saleState;
@Schema(description = "本企业是否已购买该项目(口径:该企业对该项目存在已付款订单)")
private Boolean purchased;
}
@@ -0,0 +1,18 @@
package com.gxwebsoft.hjc.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 汇吉采 证件 OCR 识别请求
*/
@Data
@Schema(name = "HjcOcrRecognizeRequest", description = "汇吉采证件OCR识别请求")
public class HjcOcrRecognizeRequest {
@Schema(description = "材料类型:idcard_front(身份证人像面)/ license(营业执照)", required = true)
private String materialType;
@Schema(description = "已上传证件的可访问地址(fileUrl)", required = true)
private String fileUrl;
}
@@ -0,0 +1,36 @@
package com.gxwebsoft.hjc.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 汇吉采 证件 OCR 识别结果
*
* <p>字段按材料类型填写营业执照填 enterpriseName/creditCode/address/legalPerson
* 身份证人像面填 agentName/idCardNo未识别到的字段为 null</p>
*/
@Data
@Schema(name = "HjcOcrResult", description = "汇吉采证件OCR识别结果")
public class HjcOcrResult {
@Schema(description = "材料类型:idcard_front / license")
private String materialType;
@Schema(description = "营业执照-企业名称(仅作只读核对,不覆盖登录账号)")
private String enterpriseName;
@Schema(description = "营业执照-统一社会信用代码")
private String creditCode;
@Schema(description = "营业执照-住所/企业地址")
private String address;
@Schema(description = "营业执照-法定代表人(仅展示供人工参考)")
private String legalPerson;
@Schema(description = "身份证-经办人姓名")
private String agentName;
@Schema(description = "身份证-经办人身份证号")
private String idCardNo;
}
@@ -0,0 +1,58 @@
package com.gxwebsoft.hjc.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
* 一站式平台 汇吉采中标公告/标书推送报文
* 对应需求字段projectNo/projectName/tenderPrice/files/tenderOnsaleTime/tenderOffsaleTime/
* bulletinName/customerName/supplierName/bidAmount/content/fileList/needSellTender/sellingMethod
*/
@Data
@Schema(name = "HjcOneStopProjectPush", description = "一站式标书/中标公告推送报文")
public class HjcOneStopProjectPush {
@Schema(description = "是否卖标书,needSellTender")
private Integer needSellTender;
@Schema(description = "售卖方式:1公司财务 2公众号 3交易中心 4政采云")
private Integer sellingMethod;
@Schema(description = "项目编号")
private String projectNo;
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "标书价格")
private BigDecimal tenderPrice;
@Schema(description = "标书附件(数组JSON或逗号分隔)")
private String files;
@Schema(description = "开售时间,yyyy-MM-dd HH:mm:ss")
private String tenderOnsaleTime;
@Schema(description = "截止时间/停售时间,yyyy-MM-dd HH:mm:ss")
private String tenderOffsaleTime;
@Schema(description = "公告标题")
private String bulletinName;
@Schema(description = "招标人")
private String customerName;
@Schema(description = "中标公司")
private String supplierName;
@Schema(description = "中标金额")
private BigDecimal bidAmount;
@Schema(description = "公告正文")
private String content;
@Schema(description = "公告附件(数组JSON或逗号分隔)")
private String fileList;
}
@@ -0,0 +1,35 @@
package com.gxwebsoft.hjc.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 密码找回申请请求体匿名
*
* <p>字段与需求一致企业名称纳税人识别号新密码授权委托书{@code handbookUrl}
* 匿名上传接口 {@code POST /api/hjc/auth/upload} 先行取得与注册链路同一做法</p>
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "HjcPasswordApplyRequest对象", description = "密码找回申请")
public class HjcPasswordApplyRequest implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "企业名称(登录账号)")
private String enterpriseName;
@Schema(description = "纳税人识别号/统一社会信用代码")
private String creditCode;
@Schema(description = "新密码(至少8位且含字母和数字)")
private String newPassword;
@Schema(description = "确认新密码")
private String confirmPassword;
@Schema(description = "本次重新上传的授权委托书文件地址")
private String handbookUrl;
}
@@ -0,0 +1,33 @@
package com.gxwebsoft.hjc.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 修改密码请求体登录态
*
* <p>两个因子都必须提供{@code oldPassword} 证明是持有人本人{@code smsCode} 证明仍持有
* 账号绑定手机号两者的校验顺序与为什么不能换
* {@code HjcPasswordController#changePassword}</p>
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "HjcPasswordChangeRequest对象", description = "修改密码")
public class HjcPasswordChangeRequest implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "旧密码")
private String oldPassword;
@Schema(description = "短信验证码(发到账号绑定手机号)")
private String smsCode;
@Schema(description = "新密码(至少8位且含字母和数字)")
private String newPassword;
@Schema(description = "确认新密码")
private String confirmPassword;
}
@@ -0,0 +1,25 @@
package com.gxwebsoft.hjc.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 标书附件下载用
*
* <p>来源是 {@code hjc_bid_project.tender_file}一站式推送的 {@code files} 字段
* 形态见 {@link com.gxwebsoft.hjc.util.HjcTenderFileUtil}**后端**解析成结构化列表后
* 随订单响应下发两种运行端 + PC 端一共三处渲染解析放前端必然分叉</p>
*
* <p><b>只在 {@code payStatus == 1}已支付的订单上出现</b>它是付费内容
* {@code HjcOrderController#attachTenderFiles}</p>
*/
@Data
@Schema(name = "HjcTenderFileVo对象", description = "标书附件(下载用)")
public class HjcTenderFileVo {
@Schema(description = "展示名(原始数据没有名称时取 URL 末段)")
private String name;
@Schema(description = "可下载地址")
private String url;
}
@@ -0,0 +1,137 @@
package com.gxwebsoft.hjc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 汇吉采标书项目项目即标书单表甲方确认一个项目一本标书
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(name = "HjcBidProject对象", description = "汇吉采标书项目(项目即标书)")
public class HjcBidProject implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "ID")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@Schema(description = "项目编号(upsert键,一站式推送用)")
private String projectNo;
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "项目分类:服务类/工程类/货物类")
private String category;
@Schema(description = "发布时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime publishTime;
@Schema(description = "投标截止时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime deadlineTime;
@Schema(description = "招标人(customerName)")
private String tenderer;
@Schema(description = "中标公司(supplierName)")
private String winnerSupplier;
@Schema(description = "中标金额(bidAmount)")
private BigDecimal bidAmount;
@Schema(description = "公告标题(bulletinName)")
private String bulletinTitle;
@Schema(description = "公告正文(content)")
private String bulletinContent;
@Schema(description = "公告附件(fileList, JSON数组字符串)")
private String bulletinFileList;
@Schema(description = "标书价格/信息服务费(tenderPrice)")
private BigDecimal tenderPrice;
@Schema(description = "标书附件(files, JSON数组字符串)")
private String tenderFile;
@Schema(description = "开售时间(tenderOnsaleTime)")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime onsaleTime;
@Schema(description = "停售时间(tenderOffsaleTime)")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime offsaleTime;
@Schema(description = "售卖方式:1公司财务 2公众号 3交易中心 4政采云")
private Integer sellingMethod;
@Schema(description = "是否卖标书(needSellTender)0否 1是")
private Integer needSell;
@Schema(description = "状态:1上架(onsale) 0下架/停售(ended)")
private Integer status;
@Schema(description = "来源:PUSH一站式推送 / MANUAL后台手工")
private String dataSource;
@Schema(description = "已售数量")
private Integer saleCount;
/**
* 购买人数**非表字段**由订单表实时统计出的已付款订单数
*
* <p>为什么不复用 {@code saleCount}它在**下单时** +quantity 且从不回退
* 含未付款单也已含被取消的单还可能被后台 {@code PUT /api/hjc/bid-project} 任意改写
* 拿它当购买人数展示一直是个假数本字段只在下单/付款之外被****不落库</p>
*
* <p>口径{@code COUNT(*) FROM hjc_order WHERE project_id=? AND pay_status=1 AND deleted=0}
* 口径与人数字面有偏差同一企业买 2 单计 2这是明确拍定的取值不要"顺手修正"</p>
*/
@Schema(description = "购买人数(实时统计的已付款订单数,非表字段)")
@TableField(exist = false)
private Integer buyerCount;
/**
* 是否已收藏**非表字段**当前登录企业是否收藏了本项目
*
* <p><b>未登录恒为 false</b>详情是公开接口不能因为收藏态查不到就把整个详情页
* 打回登录页页面上本来就有未登录分支</p>
*
* <p>口径{@code hjc_project_favorite} 里存在
* {@code (enterprise_id = 当前企业, project_id = 本项目)} 一行
* 收藏归属企业账号全企业共享一份 docs/adr/0011</p>
*/
@Schema(description = "是否已收藏(未登录恒 false,非表字段)")
@TableField(exist = false)
private Boolean favorited;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "是否删除, 0否, 1是")
@TableLogic
private Integer deleted;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
}
@@ -0,0 +1,93 @@
package com.gxwebsoft.hjc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.List;
/**
* 汇吉采企业账号与资质关联 common.system.User
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(name = "HjcEnterprise对象", description = "汇吉采企业账号与资质")
public class HjcEnterprise implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "ID")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@Schema(description = "关联登录用户ID(common user)")
private Integer userId;
@Schema(description = "企业名称")
private String name;
@Schema(description = "纳税人识别号/统一社会信用代码")
private String creditCode;
@Schema(description = "企业联系电话")
private String contactPhone;
@Schema(description = "企业邮箱")
private String contactEmail;
@Schema(description = "企业地址")
private String address;
@Schema(description = "经办人姓名")
private String agentName;
@Schema(description = "经办人邮箱")
private String agentEmail;
@Schema(description = "经办人手机号")
private String agentPhone;
@Schema(description = "经办人身份证号")
private String idCardNo;
@Schema(description = "授权委托书到期时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime authorizeExpire;
@Schema(description = "资质状态:0待审核 1已通过 2已驳回")
private Integer authStatus;
@Schema(description = "驳回原因")
private String rejectReason;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "是否删除, 0否, 1是")
@TableLogic
private Integer deleted;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
@Schema(description = "资质证件材料")
@TableField(exist = false)
private List<HjcEnterpriseMaterial> materials;
@Schema(description = "登录用户手机号(脱敏, 非DB)")
@TableField(exist = false)
private String mobile;
}
@@ -0,0 +1,55 @@
package com.gxwebsoft.hjc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 汇吉采企业资质证件材料
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(name = "HjcEnterpriseMaterial对象", description = "汇吉采企业资质证件材料")
public class HjcEnterpriseMaterial implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "ID")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@Schema(description = "企业ID")
private Integer enterpriseId;
@Schema(description = "材料类型:idcard_front/idcard_back/handbook/license")
private String materialType;
@Schema(description = "材料名称")
private String materialName;
@Schema(description = "文件地址")
private String fileUrl;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "是否删除, 0否, 1是")
@TableLogic
private Integer deleted;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
}
@@ -0,0 +1,125 @@
package com.gxwebsoft.hjc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.gxwebsoft.hjc.dto.HjcTenderFileVo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 汇吉采标书订单
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(name = "HjcOrder对象", description = "汇吉采标书订单")
public class HjcOrder implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "ID")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@Schema(description = "订单号")
private String orderNo;
@Schema(description = "标书项目ID")
private Integer projectId;
@Schema(description = "项目编号(冗余)")
private String projectNo;
@Schema(description = "项目名称(冗余)")
private String projectName;
@Schema(description = "购买企业ID")
private Integer enterpriseId;
@Schema(description = "购买企业名称(冗余)")
private String enterpriseName;
@Schema(description = "购买联系人")
private String contactName;
@Schema(description = "购买联系电话")
private String contactPhone;
@Schema(description = "购买联系邮箱")
private String contactEmail;
@Schema(description = "购买数量")
private Integer quantity;
@Schema(description = "单价(信息服务费)")
private BigDecimal unitPrice;
@Schema(description = "订单总额")
private BigDecimal totalAmount;
@Schema(description = "支付方式:WECHAT_NATIVE/ALIPAY")
private String payMethod;
@Schema(description = "支付时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime payTime;
@Schema(description = "支付状态:0待支付 1支付成功 2支付失败 3已退款")
private Integer payStatus;
@Schema(description = "退款时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime refundTime;
@Schema(description = "退款原因")
private String refundReason;
@Schema(description = "订单状态:0待支付 1已完成 2已取消")
private Integer orderStatus;
@Schema(description = "开票状态:0未开票 1已申请 2已开票")
private Integer invoiceStatus;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "是否删除, 0否, 1是")
@TableLogic
private Integer deleted;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
@Schema(description = "标书项目")
@TableField(exist = false)
private HjcBidProject project;
/**
* 标书附件**非表字段**仅在 {@code payStatus == 1} 的订单上由接口填充
*
* <p>它是**付费内容**待支付 / 支付失败 / 已退款的订单一律为 {@code null}
* 门槛的落点是 {@code HjcOrderController#attachTenderFiles}只有一处
* 附件来源是项目的 {@code tender_file} {@link com.gxwebsoft.hjc.util.HjcTenderFileUtil}
* 解析成结构化列表后再下发</p>
*
* <p>另外订单详情响应里 {@code project.tenderFile} 会被**恒置 null**
* 免得订单路径上留一条能绕过付费判定的旁路</p>
*/
@Schema(description = "标书附件(仅已支付订单返回,非表字段)")
@TableField(exist = false)
private List<HjcTenderFileVo> tenderFiles;
}
@@ -0,0 +1,71 @@
package com.gxwebsoft.hjc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 汇吉采一站式订单推送日志幂等+重试
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(name = "HjcOrderPushLog对象", description = "汇吉采一站式订单推送日志")
public class HjcOrderPushLog implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "ID")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@Schema(description = "订单ID")
private Integer orderId;
@Schema(description = "订单号(一站式幂等键)")
private String orderNo;
@Schema(description = "推送请求体(JSON)")
private String payload;
@Schema(description = "推送状态:0待推送 1成功 2失败")
private Integer pushStatus;
@Schema(description = "一站式HTTP状态码")
private Integer httpCode;
@Schema(description = "一站式返回内容")
private String responseBody;
@Schema(description = "失败原因")
private String errorMsg;
@Schema(description = "已尝试次数")
private Integer attemptCount;
@Schema(description = "下次重试时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime nextRetryTime;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "是否删除, 0否, 1是")
@TableLogic
private Integer deleted;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
}
@@ -0,0 +1,101 @@
package com.gxwebsoft.hjc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 汇吉采密码找回申请材料审核制
*
* <p><b>为什么不是一个"自助改密"流程</b>账号凭据归核心实例所有ADR-0006 hjc 对核心实例
* 只有读的能力也没有任何合法的代改密码通道审核通过密码已重置必须是两个状态
* 审核通过后由运维在核心实例管理后台照本表照单手工重置再回后台标记为已重置
* ADR-0008</p>
*
* <p><b>{@code newPassword} 是明文</b>运维要照着它往核心实例里输这是 ADR-0008
* <b>显式接受的风险</b>用户明确选择不做终态清空不记查看审计不是疏忽
* 改这一条之前请先改 ADR</p>
*
* <p>不加 {@code @TableName} hjc 既有实体一致靠驼峰转下划线映射到 {@code hjc_password_apply}</p>
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(name = "HjcPasswordApply对象", description = "汇吉采密码找回申请")
public class HjcPasswordApply implements Serializable {
private static final long serialVersionUID = 1L;
/** 状态:待审核 */
public static final int STATUS_PENDING = 0;
/** 状态:已通过(待运维在核心实例执行重置) */
public static final int STATUS_APPROVED = 1;
/** 状态:已驳回 */
public static final int STATUS_REJECTED = 2;
/** 状态:已重置(运维已执行完毕) */
public static final int STATUS_RESET = 3;
@Schema(description = "ID")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@Schema(description = "企业ID")
private Integer enterpriseId;
@Schema(description = "核心实例账号ID,供运维照单重置")
private Integer userId;
@Schema(description = "企业名称(提交时快照)")
private String enterpriseName;
@Schema(description = "纳税人识别号(提交时快照)")
private String creditCode;
@Schema(description = "申请人填写的新密码(明文,仅供后台审核详情照单重置)")
private String newPassword;
@Schema(description = "本次重新上传的授权委托书地址")
private String handbookUrl;
@Schema(description = "状态:0待审核 1已通过(待重置) 2已驳回 3已重置")
private Integer status;
@Schema(description = "驳回原因")
private String rejectReason;
@Schema(description = "审核人ID")
private Integer auditUserId;
@Schema(description = "审核时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime auditTime;
@Schema(description = "标记已重置的操作人ID")
private Integer resetUserId;
@Schema(description = "标记已重置的时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime resetTime;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "是否删除, 0否, 1是")
@TableLogic
private Integer deleted;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
}
@@ -0,0 +1,55 @@
package com.gxwebsoft.hjc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 汇吉采标书项目收藏买家我的收藏
*
* <p>不加 {@code @TableName} hjc 既有实体一致靠驼峰转下划线映射到
* {@code hjc_project_favorite} {@code HjcPasswordApply} 的注释</p>
*
* <p><b>没有 {@code deleted} 字段也没有 {@code @TableLogic}</b>取消收藏就是物理删除
* 理由与先例见 docs/adr/0012-收藏关系不做逻辑删除.md
* 逻辑删除 + {@code (enterprise_id, project_id)} 唯一键是互斥的且收藏关系没有审计价值
* <b>不要顺手把 {@code deleted} 补上</b></p>
*
* <p>归属键是 {@code enterpriseId}{@code hjc_enterprise.id}不是核心实例的 {@code userId}
* 收藏是企业账号级的全企业共享一份 docs/adr/0011-收藏归属企业账号而非操作人.md</p>
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(name = "HjcProjectFavorite对象", description = "汇吉采标书项目收藏")
public class HjcProjectFavorite implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "ID")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@Schema(description = "企业ID(hjc_enterprise.id)")
private Integer enterpriseId;
@Schema(description = "标书项目ID(hjc_bid_project.id)")
private Integer projectId;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "收藏时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
}
@@ -0,0 +1,22 @@
package com.gxwebsoft.hjc.mapper;
import com.gxwebsoft.hjc.dto.HjcBannerVo;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 买家端轮播图查询只读 CMS 轮播组表
*
* <p>不继承 BaseMapper Mapper 只做跨模块只读查询不写任何表
* 租户隔离由 MyBatis-Plus 租户拦截器按请求头 tenantId 自动附加</p>
*/
public interface HjcBannerMapper {
/**
* 查询启用中的轮播项扁平化已过滤停用/已删除/不在生效时间窗口内的数据
*
* @param position 展示位置标识 home_slider为空时不过滤位置
*/
List<HjcBannerVo> selectEnabledList(@Param("position") String position);
}
@@ -0,0 +1,23 @@
package com.gxwebsoft.hjc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.gxwebsoft.hjc.entity.HjcBidProject;
import com.gxwebsoft.hjc.param.HjcBidProjectParam;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface HjcBidProjectMapper extends BaseMapper<HjcBidProject> {
List<HjcBidProject> selectPageRel(@Param("page") IPage<HjcBidProject> page, @Param("param") HjcBidProjectParam param);
List<HjcBidProject> selectListRel(@Param("param") HjcBidProjectParam param);
/**
* 按项目编号取标书项目忽略租户隔离用于一站式推送 upsert
*/
@InterceptorIgnore(tenantLine = "true")
HjcBidProject getByProjectNo(@Param("projectNo") String projectNo);
}
@@ -0,0 +1,40 @@
package com.gxwebsoft.hjc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.param.HjcEnterpriseParam;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface HjcEnterpriseMapper extends BaseMapper<HjcEnterprise> {
List<HjcEnterprise> selectPageRel(@Param("page") IPage<HjcEnterprise> page, @Param("param") HjcEnterpriseParam param);
List<HjcEnterprise> selectListRel(@Param("param") HjcEnterpriseParam param);
/**
* 按登录用户ID取企业忽略租户隔离用于登录后取企业资料
*/
@InterceptorIgnore(tenantLine = "true")
HjcEnterprise getByUserId(@Param("userId") Integer userId);
/**
* 企业名称 + 纳税人识别号双要素取企业忽略租户隔离显式传 tenantId
*
* <p>用于<b>匿名</b>的密码找回申请与进度查询只允许精确匹配两者必须同时命中同一份档案</p>
*
* <p>为什么是双要素{@code hjc_enterprise.name} <b>没有唯一索引</b>{@code hjc_init.sql} 里只有
* {@code user_id} 唯一只用企业名称定位的话同名企业会指向不确定的账号那等于把 A 企业的
* 密码重置落到 B 账号上加上同样有索引的 {@code credit_code} 后歧义天然消失</p>
*
* <p>为什么要忽略租户隔离多租户插件的租户来源是请求头 {@code tenantId}匿名请求不带它时条件会被
* 拼成 {@code tenant_id = NULL} 恒不命中故照 {@link #getByUserId} 的做法显式忽略并自带租户条件</p>
*/
@InterceptorIgnore(tenantLine = "true")
List<HjcEnterprise> getByNameAndCreditCode(@Param("name") String name,
@Param("creditCode") String creditCode,
@Param("tenantId") Integer tenantId);
}
@@ -0,0 +1,16 @@
package com.gxwebsoft.hjc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial;
import com.gxwebsoft.hjc.param.HjcEnterpriseMaterialParam;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface HjcEnterpriseMaterialMapper extends BaseMapper<HjcEnterpriseMaterial> {
List<HjcEnterpriseMaterial> selectPageRel(@Param("page") IPage<HjcEnterpriseMaterial> page, @Param("param") HjcEnterpriseMaterialParam param);
List<HjcEnterpriseMaterial> selectListRel(@Param("param") HjcEnterpriseMaterialParam param);
}
@@ -0,0 +1,59 @@
package com.gxwebsoft.hjc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.gxwebsoft.hjc.entity.HjcOrder;
import com.gxwebsoft.hjc.param.HjcOrderParam;
import org.apache.ibatis.annotations.Param;
import java.util.Collection;
import java.util.List;
public interface HjcOrderMapper extends BaseMapper<HjcOrder> {
List<HjcOrder> selectPageRel(@Param("page") IPage<HjcOrder> page, @Param("param") HjcOrderParam param);
List<HjcOrder> selectListRel(@Param("param") HjcOrderParam param);
/**
* 按订单号取订单忽略租户隔离用于支付回调
*/
@InterceptorIgnore(tenantLine = "true")
HjcOrder getByOrderNo(@Param("orderNo") String orderNo);
/**
* 未支付且未取消的订单置为已取消order_status=2
*
* <p>条件更新是取消动作**幂等的唯一保证**只有把状态从 0 翻到 2 的那一次调用会返回 1
* 因此它必须返回**影响行数**{@code updateById(entity)} 那条路拿不到行数故单独写一条 XML update</p>
*
* <p>{@code pay_status = 0} {@code order_status = 0} 同时作为条件避免把已支付或已取消的单改掉</p>
*
* @return 实际影响行数0 1
*/
int cancelPending(@Param("orderNo") String orderNo);
/**
* 统计某标书项目**已付款**的订单数用于标书详情展示购买人数
*
* <p>口径{@code COUNT(*) WHERE project_id=? AND pay_status=1 AND deleted=0}
* 退款单pay_status=3与已取消单pay_status 仍为 0都不计</p>
*/
int countPaidOrders(@Param("projectId") Integer projectId);
/**
* 该企业已付款过的项目 id 集合用于我的收藏里的已购买标记
*
* <p>口径与 {@link #countPaidOrders(Integer)} 完全一致只看 {@code pay_status = 1}
* 退款单{@code pay_status = 3}与未付款单都不算
* **同一个概念不许有第二套口径**</p>
*
* <p>放在订单 mapper 上而不是收藏 mapper 它查的是 {@code hjc_order}
* 表归属该由它的 mapper 持有</p>
*
* @param projectIds 调用方需保证**非空**空集合会生成 {@code IN ()} 的语法错误
*/
List<Integer> listPaidProjectIds(@Param("enterpriseId") Integer enterpriseId,
@Param("projectIds") Collection<Integer> projectIds);
}
@@ -0,0 +1,16 @@
package com.gxwebsoft.hjc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.gxwebsoft.hjc.entity.HjcOrderPushLog;
import com.gxwebsoft.hjc.param.HjcOrderPushLogParam;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface HjcOrderPushLogMapper extends BaseMapper<HjcOrderPushLog> {
List<HjcOrderPushLog> selectPageRel(@Param("page") IPage<HjcOrderPushLog> page, @Param("param") HjcOrderPushLogParam param);
List<HjcOrderPushLog> selectListRel(@Param("param") HjcOrderPushLogParam param);
}
@@ -0,0 +1,37 @@
package com.gxwebsoft.hjc.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.gxwebsoft.hjc.entity.HjcPasswordApply;
import com.gxwebsoft.hjc.param.HjcPasswordApplyParam;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface HjcPasswordApplyMapper extends BaseMapper<HjcPasswordApply> {
List<HjcPasswordApply> selectPageRel(@Param("page") IPage<HjcPasswordApply> page, @Param("param") HjcPasswordApplyParam param);
List<HjcPasswordApply> selectListRel(@Param("param") HjcPasswordApplyParam param);
/**
* 取某企业最新一条申请忽略租户隔离显式传 tenantId
*
* <p>为什么忽略这条查询用在<b>匿名</b>的申请与进度查询链路上多租户插件的租户来源是
* 请求头 {@code tenantId}{@code MybatisPlusConfig} tenantLineHandler匿名请求不带它时
* 条件会被拼成 {@code tenant_id = NULL} 恒不命中前端目前确实恒带该头但把正确性押在
* 一个可被省略的头上太脆故这里照 {@code HjcEnterpriseMapper.getByUserId} 的做法
* 显式忽略并自带租户条件</p>
*/
@InterceptorIgnore(tenantLine = "true")
HjcPasswordApply getLatestByEnterpriseId(@Param("enterpriseId") Integer enterpriseId,
@Param("tenantId") Integer tenantId);
/**
* 取某企业最新一条待审核申请用于重复提交不新建语义与 {@link #getLatestByEnterpriseId} 同一处理
*/
@InterceptorIgnore(tenantLine = "true")
HjcPasswordApply getPendingByEnterpriseId(@Param("enterpriseId") Integer enterpriseId,
@Param("tenantId") Integer tenantId);
}
@@ -0,0 +1,29 @@
package com.gxwebsoft.hjc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.gxwebsoft.hjc.dto.HjcFavoriteVo;
import com.gxwebsoft.hjc.entity.HjcProjectFavorite;
import com.gxwebsoft.hjc.param.HjcProjectFavoriteParam;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 汇吉采标书项目收藏
*
* <p>只保留一条自定义查询连表分页是否已收藏收藏取消收藏都由
* {@code BaseMapper} wrapper 完成租户条件由租户拦截器自动附加
* 且本实体**没有** {@code @TableLogic}所以 {@code delete} 就是物理删除ADR 0012</p>
*/
public interface HjcProjectFavoriteMapper extends BaseMapper<HjcProjectFavorite> {
/**
* 我的收藏分页收藏行 + 项目当前值实时
*
* <p>排序**不在 XML ** service 设进 {@code PageParam}若这里手写 ORDER BY
* MyBatis-Plus 分页插件还会再追加一段直接语法错误</p>
*/
List<HjcFavoriteVo> selectPageRel(@Param("page") IPage<HjcFavoriteVo> page,
@Param("param") HjcProjectFavoriteParam param);
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.hjc.mapper.HjcBannerMapper">
<!--
启用中的轮播项(只读):
- 只取「启用分组(g.status=1) + 未删除分组/明细 + 生效时间窗口内」的数据;
- 生效窗口用数据库 NOW() 比较,避免 JVM 与 DB 时区不一致导致误判(与 CMS 后台配置口径一致);
- 无图的明细直接剔除,前端不会拿到空白轮播位;
- 组内按 sort_number 升序,与其他模块共用一份排序规则。
租户隔离由 MyBatis-Plus 租户拦截器按请求头 tenantId 自动附加到两张表。
-->
<select id="selectEnabledList" resultType="com.gxwebsoft.hjc.dto.HjcBannerVo">
SELECT i.item_id,
i.group_id,
COALESCE(NULLIF(i.title, ''), g.title) AS title,
i.subtitle,
i.image,
i.link_type,
i.link_url,
i.link_target_id,
i.sort_number
FROM cms_banner_item i
INNER JOIN cms_banner_group g ON g.group_id = i.group_id
WHERE i.deleted = 0
AND i.image IS NOT NULL
AND i.image &lt;&gt; ''
AND g.deleted = 0
AND g.status = 1
AND (g.start_time IS NULL OR g.start_time &lt;= NOW())
AND (g.end_time IS NULL OR g.end_time &gt;= NOW())
<if test="position != null and position != ''">
AND g.position = #{position}
</if>
ORDER BY g.sort_number ASC, i.sort_number ASC, i.item_id ASC
</select>
</mapper>
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.hjc.mapper.HjcBidProjectMapper">
<!-- 关联查询sql -->
<sql id="selectSql">
SELECT a.*
FROM hjc_bid_project a
<where>
<if test="param.id != null">
AND a.id = #{param.id}
</if>
<if test="param.projectNo != null">
AND a.project_no = #{param.projectNo}
</if>
<if test="param.projectName != null">
AND a.project_name LIKE CONCAT('%', #{param.projectName}, '%')
</if>
<if test="param.category != null">
AND a.category = #{param.category}
</if>
<if test="param.status != null">
AND a.status = #{param.status}
</if>
<if test="param.sellingMethod != null">
AND a.selling_method = #{param.sellingMethod}
</if>
<if test="param.needSell != null">
AND a.need_sell = #{param.needSell}
</if>
<if test="param.dataSource != null">
AND a.data_source = #{param.dataSource}
</if>
<if test="param.createTimeStart != null">
AND a.create_time &gt;= #{param.createTimeStart}
</if>
<if test="param.createTimeEnd != null">
AND a.create_time &lt;= #{param.createTimeEnd}
</if>
<if test="param.keywords != null">
AND a.project_name LIKE CONCAT('%', #{param.keywords}, '%')
</if>
</where>
</sql>
<select id="selectPageRel" resultType="com.gxwebsoft.hjc.entity.HjcBidProject">
<include refid="selectSql"></include>
</select>
<select id="selectListRel" resultType="com.gxwebsoft.hjc.entity.HjcBidProject">
<include refid="selectSql"></include>
</select>
<select id="getByProjectNo" resultType="com.gxwebsoft.hjc.entity.HjcBidProject">
SELECT a.*
FROM hjc_bid_project a
WHERE a.project_no = #{projectNo}
LIMIT 1
</select>
</mapper>
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.hjc.mapper.HjcEnterpriseMapper">
<!-- 关联查询sql -->
<sql id="selectSql">
SELECT a.*
FROM hjc_enterprise a
<where>
<if test="param.id != null">
AND a.id = #{param.id}
</if>
<if test="param.userId != null">
AND a.user_id = #{param.userId}
</if>
<if test="param.name != null">
AND a.name LIKE CONCAT('%', #{param.name}, '%')
</if>
<if test="param.creditCode != null">
AND a.credit_code = #{param.creditCode}
</if>
<if test="param.authStatus != null">
AND a.auth_status = #{param.authStatus}
</if>
<if test="param.createTimeStart != null">
AND a.create_time &gt;= #{param.createTimeStart}
</if>
<if test="param.createTimeEnd != null">
AND a.create_time &lt;= #{param.createTimeEnd}
</if>
<if test="param.keywords != null">
AND a.name LIKE CONCAT('%', #{param.keywords}, '%')
</if>
</where>
</sql>
<select id="selectPageRel" resultType="com.gxwebsoft.hjc.entity.HjcEnterprise">
<include refid="selectSql"></include>
</select>
<select id="selectListRel" resultType="com.gxwebsoft.hjc.entity.HjcEnterprise">
<include refid="selectSql"></include>
</select>
<select id="getByUserId" resultType="com.gxwebsoft.hjc.entity.HjcEnterprise">
SELECT a.*
FROM hjc_enterprise a
WHERE a.user_id = #{userId}
LIMIT 1
</select>
<!-- 密码找回:双要素精确匹配,租户条件显式写在 SQL 里(方法上有 @InterceptorIgnore)。
不 LIMIT 1:命中多条属于脏数据,必须让调用方看见并拒绝,不能静默取一条。 -->
<select id="getByNameAndCreditCode" resultType="com.gxwebsoft.hjc.entity.HjcEnterprise">
SELECT a.*
FROM hjc_enterprise a
WHERE a.name = #{name}
AND a.credit_code = #{creditCode}
AND a.tenant_id = #{tenantId}
ORDER BY a.id ASC
</select>
</mapper>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.hjc.mapper.HjcEnterpriseMaterialMapper">
<!-- 关联查询sql -->
<sql id="selectSql">
SELECT a.*
FROM hjc_enterprise_material a
<where>
<if test="param.id != null">
AND a.id = #{param.id}
</if>
<if test="param.enterpriseId != null">
AND a.enterprise_id = #{param.enterpriseId}
</if>
<if test="param.materialType != null">
AND a.material_type = #{param.materialType}
</if>
<if test="param.createTimeStart != null">
AND a.create_time &gt;= #{param.createTimeStart}
</if>
<if test="param.createTimeEnd != null">
AND a.create_time &lt;= #{param.createTimeEnd}
</if>
</where>
</sql>
<select id="selectPageRel" resultType="com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial">
<include refid="selectSql"></include>
</select>
<select id="selectListRel" resultType="com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial">
<include refid="selectSql"></include>
</select>
</mapper>
@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.hjc.mapper.HjcOrderMapper">
<!-- 关联查询sql -->
<sql id="selectSql">
SELECT a.*
FROM hjc_order a
<where>
<if test="param.id != null">
AND a.id = #{param.id}
</if>
<if test="param.orderNo != null">
AND a.order_no = #{param.orderNo}
</if>
<if test="param.projectId != null">
AND a.project_id = #{param.projectId}
</if>
<if test="param.projectNo != null">
AND a.project_no = #{param.projectNo}
</if>
<if test="param.enterpriseId != null">
AND a.enterprise_id = #{param.enterpriseId}
</if>
<if test="param.projectName != null">
AND a.project_name LIKE CONCAT('%', #{param.projectName}, '%')
</if>
<if test="param.payStatus != null">
AND a.pay_status = #{param.payStatus}
</if>
<if test="param.orderStatus != null">
AND a.order_status = #{param.orderStatus}
</if>
<if test="param.payMethod != null">
AND a.pay_method = #{param.payMethod}
</if>
<if test="param.createTimeStart != null">
AND a.create_time &gt;= #{param.createTimeStart}
</if>
<if test="param.createTimeEnd != null">
AND a.create_time &lt;= #{param.createTimeEnd}
</if>
<if test="param.keywords != null">
AND a.project_name LIKE CONCAT('%', #{param.keywords}, '%')
</if>
</where>
</sql>
<select id="selectPageRel" resultType="com.gxwebsoft.hjc.entity.HjcOrder">
<include refid="selectSql"></include>
</select>
<select id="selectListRel" resultType="com.gxwebsoft.hjc.entity.HjcOrder">
<include refid="selectSql"></include>
</select>
<select id="getByOrderNo" resultType="com.gxwebsoft.hjc.entity.HjcOrder">
SELECT a.*
FROM hjc_order a
WHERE a.order_no = #{orderNo}
LIMIT 1
</select>
<!--
取消待支付订单:条件更新,返回影响行数。
`pay_status = 0 AND order_status = 0` 是幂等与安全的双重保证:
- 重复调用时第二次影响 0 行,不会重复产生副作用;
- 已支付(pay_status=1/3)与已取消(order_status=2)的单都不会被改到。
这里**不加** `@InterceptorIgnore(tenantLine)`:调用方是已登录买家、且已按 enterpriseId 校验过归属,
租户条件留着多一层保护,没有代价。
-->
<update id="cancelPending">
UPDATE hjc_order
SET order_status = 2,
update_time = NOW()
WHERE order_no = #{orderNo}
AND pay_status = 0
AND order_status = 0
AND deleted = 0
</update>
<!-- 标书详情「购买人数」:已付款订单数(退款单与已取消单都不计) -->
<select id="countPaidOrders" resultType="int">
SELECT COUNT(*)
FROM hjc_order
WHERE project_id = #{projectId}
AND pay_status = 1
AND deleted = 0
</select>
<!--
「我的收藏」的「已购买」标记:该企业已付款过的项目 id 集合。
口径与 countPaidOrders 完全一致(只看 pay_status = 1),同一个概念不许有第二套口径。
用 DISTINCT:同一企业对同一项目可能有多张已付款订单,只要一条就够。
调用方保证 projectIds 非空(空集合会生成 `IN ()`)。
租户条件由租户拦截器自动附加,不手写。
-->
<select id="listPaidProjectIds" resultType="int">
SELECT DISTINCT project_id
FROM hjc_order
WHERE enterprise_id = #{enterpriseId}
AND pay_status = 1
AND deleted = 0
AND project_id IN
<foreach collection="projectIds" item="pid" open="(" close=")" separator=",">
#{pid}
</foreach>
</select>
</mapper>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.hjc.mapper.HjcOrderPushLogMapper">
<!-- 关联查询sql -->
<sql id="selectSql">
SELECT a.*
FROM hjc_order_push_log a
<where>
<if test="param.id != null">
AND a.id = #{param.id}
</if>
<if test="param.orderId != null">
AND a.order_id = #{param.orderId}
</if>
<if test="param.orderNo != null">
AND a.order_no = #{param.orderNo}
</if>
<if test="param.pushStatus != null">
AND a.push_status = #{param.pushStatus}
</if>
<if test="param.createTimeStart != null">
AND a.create_time &gt;= #{param.createTimeStart}
</if>
<if test="param.createTimeEnd != null">
AND a.create_time &lt;= #{param.createTimeEnd}
</if>
</where>
</sql>
<select id="selectPageRel" resultType="com.gxwebsoft.hjc.entity.HjcOrderPushLog">
<include refid="selectSql"></include>
</select>
<select id="selectListRel" resultType="com.gxwebsoft.hjc.entity.HjcOrderPushLog">
<include refid="selectSql"></include>
</select>
</mapper>
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.hjc.mapper.HjcPasswordApplyMapper">
<!-- 关联查询sql -->
<sql id="selectSql">
SELECT a.*
FROM hjc_password_apply a
<where>
<if test="param.id != null">
AND a.id = #{param.id}
</if>
<if test="param.enterpriseId != null">
AND a.enterprise_id = #{param.enterpriseId}
</if>
<if test="param.enterpriseName != null">
AND a.enterprise_name LIKE CONCAT('%', #{param.enterpriseName}, '%')
</if>
<if test="param.creditCode != null">
AND a.credit_code = #{param.creditCode}
</if>
<if test="param.status != null">
AND a.status = #{param.status}
</if>
<if test="param.createTimeStart != null">
AND a.create_time &gt;= #{param.createTimeStart}
</if>
<if test="param.createTimeEnd != null">
AND a.create_time &lt;= #{param.createTimeEnd}
</if>
</where>
</sql>
<select id="selectPageRel" resultType="com.gxwebsoft.hjc.entity.HjcPasswordApply">
<include refid="selectSql"></include>
</select>
<select id="selectListRel" resultType="com.gxwebsoft.hjc.entity.HjcPasswordApply">
<include refid="selectSql"></include>
</select>
<!-- 最新一条申请:只用于匿名链路,租户条件显式写在 SQL 里(方法上有 @InterceptorIgnore -->
<select id="getLatestByEnterpriseId" resultType="com.gxwebsoft.hjc.entity.HjcPasswordApply">
SELECT a.*
FROM hjc_password_apply a
WHERE a.enterprise_id = #{enterpriseId}
AND a.tenant_id = #{tenantId}
ORDER BY a.id DESC
LIMIT 1
</select>
<!-- 最新一条待审核申请:命中即「重复提交」,直接返回它而不新建 -->
<select id="getPendingByEnterpriseId" resultType="com.gxwebsoft.hjc.entity.HjcPasswordApply">
SELECT a.*
FROM hjc_password_apply a
WHERE a.enterprise_id = #{enterpriseId}
AND a.tenant_id = #{tenantId}
AND a.status = 0
ORDER BY a.id DESC
LIMIT 1
</select>
</mapper>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.hjc.mapper.HjcProjectFavoriteMapper">
<!--
「我的收藏」分页:收藏行 + 项目**当前值**(实时,不做收藏时的快照)。
两条与仓库里其他 JOIN 查询**相反**的约定,都是刻意的:
1. LEFT JOIN 上**没有** `b.deleted = 0`。hjc_bid_project 是逻辑删除表,但这里必须把
「已下架 / 已被删除」的项目**留在结果里**(前端显示为「已下架」),
而不是让一条收藏凭空消失。自定义 XML 不受 MyBatis-Plus 逻辑删除影响,
所以正确的写法就是「什么都不加」——仓库里其他 JOIN 都手写 deleted = 0
(如 HjcBannerMapper.xml),**唯独这张表不许照抄**。
见 .scratch/hjc-favorite/spec.md 决策 8。
2. 租户条件**不手写**:租户拦截器会自动给 JOIN 的两张表都附加 tenant_id
HjcBannerMapper.xml 的注释记录了这个行为)。
`b.id AS joined_project_id` 是「项目还在不在」唯一可靠的判据:它为 null 即项目已被物理删除,
不要用 project_name 是否为 null 去猜。
排序不在这里写(见 HjcProjectFavoriteMapper#selectPageRel 的注释)。
-->
<select id="selectPageRel" resultType="com.gxwebsoft.hjc.dto.HjcFavoriteVo">
SELECT a.id,
a.project_id,
a.create_time,
b.id AS joined_project_id,
b.project_name,
b.project_no,
b.category,
b.tender_price,
b.deadline_time,
b.offsale_time,
b.status AS project_status,
b.deleted AS project_deleted
FROM hjc_project_favorite a
LEFT JOIN hjc_bid_project b ON b.id = a.project_id
WHERE a.enterprise_id = #{param.enterpriseId}
</select>
</mapper>
@@ -0,0 +1,267 @@
package com.gxwebsoft.hjc.ocr;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.alibaba.fastjson.JSONObject;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 百度云 OCR REST 客户端身份证识别 / 营业执照识别
*
* <p>接口文档<a href="https://ai.baidu.com/ai-doc/OCR/rk3h7xzck">身份证识别</a>
* <a href="https://ai.baidu.com/ai-doc/OCR/sk3h7y3zs">营业执照识别</a>
* 调用方式为先取 access_token再以表单 POST base64 图片不引入百度官方 Java SDK
* 只用项目已有的 hutool-http + fastjson</p>
*
* <p><b>token 缓存</b>access_token 有效期 30 天且<b>跨接口共用</b>按应用api-key/secret-key维度缓存
* 本项目为单实例部署故只在进程内缓存{@code volatile} + 到期时间戳 + 双重检查刷新
* 不落 Redis避免引入一层与 OCR 无关的失败模式若将来改多实例部署只需改造
* {@link #currentAccessToken()} {@link #refreshAccessToken()}</p>
*
* <p><b>失败语义</b>任何失败都抛 {@link HjcOcrException}携带百度云 {@code error_code}/{@code log_id}
* 或本平台侧错误码由调用方决定日志与返回不重试QPS/额度类错误重试只会更糟</p>
*
* <p>本类不打印任何识别内容姓名身份证号属个人信息只记录字段名与错误码</p>
*/
@Component
public class HjcBaiduOcrClient {
/** 获取 access_tokenPOST,参数 grant_type/client_id/client_secret */
private static final String TOKEN_URL = "https://aip.baidubce.com/oauth/2.0/token";
/** 身份证识别:id_card_side 必填,front=人像面 */
private static final String IDCARD_URL = "https://aip.baidubce.com/rest/2.0/ocr/v1/idcard";
/** 营业执照识别 */
private static final String BUSINESS_LICENSE_URL = "https://aip.baidubce.com/rest/2.0/ocr/v1/business_license";
/** 连接超时:百度云为公网服务,2s 足够 */
static final int CONNECT_TIMEOUT_MS = 2000;
/** 读取超时:base64 图片上行 + 识别,给 8s */
static final int READ_TIMEOUT_MS = 8000;
/** 提前刷新窗口:缓存的到期时间 = 拿到 token 的时刻 + (expires_in - 本窗口),即有效期还剩不到 1 小时就重新取 */
static final long TOKEN_REFRESH_AHEAD_MS = 60L * 60L * 1000L;
/** token 有效期兜底(百度云返回 expires_in 缺失时按 30 天算) */
static final long DEFAULT_TOKEN_TTL_MS = 30L * 24L * 60L * 60L * 1000L;
/** 刷新后至少缓存 1 分钟,防止 expires_in 异常小导致每次都请求 token */
static final long MIN_TOKEN_TTL_MS = 60L * 1000L;
/** 身份证人像面:id_card_side=front */
static final String ID_CARD_SIDE_FRONT = "front";
@Value("${baidu.ocr.api-key:}")
private String apiKey;
@Value("${baidu.ocr.secret-key:}")
private String secretKey;
/** 缓存的 access_token */
private volatile String accessToken;
/** 缓存到期时间(epoch millis */
private volatile long accessTokenExpireAt;
/**
* 凭据是否已配置未配置则调用方跳过识别不阻断注册
*/
public boolean isConfigured() {
return StrUtil.isNotBlank(apiKey) && StrUtil.isNotBlank(secretKey);
}
/**
* 身份证人像面识别
*
* @param imageBytes 图片原始字节内部做 base64 + urlencode
* @return 识别结果{@code words_result} 的中文 key 文本
*/
public Result recognizeIdCardFront(byte[] imageBytes) {
return recognize(IDCARD_URL, imageBytes, "id_card_side", ID_CARD_SIDE_FRONT);
}
/**
* 营业执照识别
*
* @param imageBytes 图片原始字节内部做 base64 + urlencode
* @return 识别结果{@code words_result} 的中文 key 文本
*/
public Result recognizeBusinessLicense(byte[] imageBytes) {
return recognize(BUSINESS_LICENSE_URL, imageBytes, null, null);
}
private Result recognize(String apiUrl, byte[] imageBytes, String extraName, String extraValue) {
String token = currentAccessToken();
HttpRequest request = HttpRequest.post(apiUrl + "?access_token=" + token)
.setConnectionTimeout(CONNECT_TIMEOUT_MS)
.timeout(READ_TIMEOUT_MS)
.form("image", Base64.getEncoder().encodeToString(imageBytes));
if (extraName != null) {
request.form(extraName, extraValue);
}
JSONObject json = parseJson(execute(request), "OCR 识别");
int errorCode = json.getIntValue("error_code");
if (errorCode != 0) {
String errorMsg = json.getString("error_msg");
String logId = json.getString("log_id");
if (isTokenInvalid(errorCode)) {
// 不是重试只是让下一次调用重新取 token避免继续用失效 token
invalidateAccessToken();
}
throw new HjcOcrException(errorCode, errorMsg, logId);
}
Result result = new Result();
result.setLogId(json.getString("log_id"));
// 身份证接口专属normal / reversed_side正反面传错/ blurred / non_idcard
result.setImageStatus(json.getString("image_status"));
result.setWords(extractWords(json));
return result;
}
/**
* 取可用的 access_token命中缓存直接返回否则同步刷新双重检查避免并发重复取 token
*/
private String currentAccessToken() {
String cached = this.accessToken;
if (cached != null && System.currentTimeMillis() < this.accessTokenExpireAt) {
return cached;
}
synchronized (this) {
if (this.accessToken != null && System.currentTimeMillis() < this.accessTokenExpireAt) {
return this.accessToken;
}
refreshAccessToken();
return this.accessToken;
}
}
private void refreshAccessToken() {
HttpRequest request = HttpRequest.post(TOKEN_URL)
.setConnectionTimeout(CONNECT_TIMEOUT_MS)
.timeout(READ_TIMEOUT_MS)
.form("grant_type", "client_credentials")
.form("client_id", apiKey)
.form("client_secret", secretKey);
JSONObject json = parseJson(execute(request), "获取 access_token");
String token = json.getString("access_token");
if (StrUtil.isBlank(token)) {
// 失败响应形如 {"error":"invalid_client","error_description":"unknown client id"}
throw new HjcOcrException(HjcOcrException.ERROR_TOKEN_FETCH,
"获取 access_token 失败:" + describeClientError(json), null);
}
long expiresInSeconds = json.getLongValue("expires_in");
long ttlMillis = expiresInSeconds > 0 ? expiresInSeconds * 1000L : DEFAULT_TOKEN_TTL_MS;
this.accessToken = token;
this.accessTokenExpireAt = System.currentTimeMillis()
+ Math.max(ttlMillis - TOKEN_REFRESH_AHEAD_MS, MIN_TOKEN_TTL_MS);
}
private void invalidateAccessToken() {
this.accessToken = null;
this.accessTokenExpireAt = 0L;
}
/**
* 执行一次 HTTP 调用并返回响应体文本
*
* <p>该方法是唯一的 HTTP 出口测试中可覆盖以注入打桩响应从而在不联网不依赖百度额度的情况下
* 覆盖 token 缓存与各错误码分支</p>
*/
protected String execute(HttpRequest request) {
try (HttpResponse response = request.execute()) {
String body = response.body();
if (StrUtil.isBlank(body)) {
throw new HjcOcrException(HjcOcrException.ERROR_RESPONSE_INVALID,
"百度云 OCR 响应为空,HTTP " + response.getStatus(), null);
}
return body;
} catch (HjcOcrException e) {
throw e;
} catch (Exception e) {
throw new HjcOcrException(HjcOcrException.ERROR_RESPONSE_INVALID,
"调用百度云 OCR 失败:" + e.getMessage(), null);
}
}
private static JSONObject parseJson(String body, String action) {
try {
return JSONObject.parseObject(body);
} catch (Exception e) {
throw new HjcOcrException(HjcOcrException.ERROR_RESPONSE_INVALID,
action + " 响应不是合法 JSON" + brief(body), null);
}
}
/** {@code words_result} 形如 {"姓名":{"location":{...},"words":"张三"}} */
private static Map<String, String> extractWords(JSONObject json) {
Map<String, String> words = new LinkedHashMap<>();
JSONObject wordsResult = json.getJSONObject("words_result");
if (wordsResult == null) {
return words;
}
for (String key : wordsResult.keySet()) {
JSONObject item = wordsResult.getJSONObject(key);
words.put(key, item == null ? null : item.getString("words"));
}
return words;
}
/** 100/110/111token 无效或过期,需丢弃缓存 */
private static boolean isTokenInvalid(int errorCode) {
return errorCode == 100 || errorCode == 110 || errorCode == 111;
}
private static String describeClientError(JSONObject json) {
String error = json.getString("error");
String description = json.getString("error_description");
return StrUtil.isBlank(error) ? brief(json.toJSONString()) : error + " / " + description;
}
private static String brief(String text) {
if (text == null) {
return null;
}
return text.length() <= 200 ? text : text.substring(0, 200) + "...";
}
/**
* 百度云 OCR 识别结果
*/
public static class Result {
/** {@code words_result}:中文 key → 文本(未识别字段的文本为「无」) */
private Map<String, String> words = new LinkedHashMap<>();
/** 百度云 log_id,用于向百度云排查 */
private String logId;
/** 身份证接口返回:normal / reversed_side / blurred / non_idcard 等 */
private String imageStatus;
public Map<String, String> getWords() {
return words;
}
public void setWords(Map<String, String> words) {
this.words = words;
}
public String getLogId() {
return logId;
}
public void setLogId(String logId) {
this.logId = logId;
}
public String getImageStatus() {
return imageStatus;
}
public void setImageStatus(String imageStatus) {
this.imageStatus = imageStatus;
}
}
}
@@ -0,0 +1,58 @@
package com.gxwebsoft.hjc.ocr;
/**
* 汇吉采 证件 OCR 失败异常
*
* <p>错误码有两类便于日志排查</p>
* <ul>
* <li><b>正数</b>百度云返回的 {@code error_code} 6 未开通接口17/18/19 额度与 QPS 超限
* 110/111 token 失效216201/216202 图片格式与大小错误282102/282112/282114 识别与 URL 抓取失败</li>
* <li><b>负数</b>本平台侧的错误码见本类常量取图失败token 获取失败响应无法解析图片不合规
* 未识别到字段</li>
* </ul>
*
* <p>本异常只在服务端流转用于打日志<b>不向接口调用方暴露细节</b>Controller 仍统一返回
* 识别失败或该类型无需识别请手动填写</p>
*/
public class HjcOcrException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** 本平台侧:取回证件图片失败(我方下载 fileUrl 失败) */
public static final int ERROR_IMAGE_FETCH = -1;
/** 本平台侧:获取 access_token 失败 */
public static final int ERROR_TOKEN_FETCH = -2;
/** 本平台侧:百度云响应不是合法 JSON */
public static final int ERROR_RESPONSE_INVALID = -3;
/** 本平台侧:图片超过接口大小上限,未调用百度云 */
public static final int ERROR_IMAGE_TOO_LARGE = -4;
/** 本平台侧:图片最短边不足,未调用百度云 */
public static final int ERROR_IMAGE_TOO_SMALL = -5;
/** 本平台侧:调用成功但一个字段都没识别到(常见于身份证正反面传错) */
public static final int ERROR_NO_FIELD = -6;
/** 错误码:正数为百度云 error_code,负数为本平台侧错误码 */
private final int errorCode;
/** 百度云 log_id,便于向百度云排查;本平台侧错误时为 null */
private final String logId;
public HjcOcrException(int errorCode, String message, String logId) {
super(message);
this.errorCode = errorCode;
this.logId = logId;
}
public int getErrorCode() {
return errorCode;
}
public String getLogId() {
return logId;
}
/** 是否百度云返回的错误(正数) */
public boolean isBaiduError() {
return errorCode > 0;
}
}
@@ -0,0 +1,60 @@
package com.gxwebsoft.hjc.ocr;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import org.springframework.stereotype.Component;
/**
* 证件图片取回器把前端上传后返回的 {@code fileUrl} 取成字节供百度云 OCR base64 方式上传
*
* <p><b>为什么由我方取图而不是把 URL 交给百度云回源抓取</b>URL 模式依赖第三方能抓到我方文件地址
* 这条链路不受本平台控制且百度云对 URL 模式还有 {@code 282112}URL 下载超时含图片 >3M防盗链
* 这类难以自查的失败改成我方取图后取图失败变成可判断可打日志的本地事件</p>
*
* <p>只认 HTTP 200 + {@code image/*}证件地址若指向 404/HTML例如文件服务未正确映射路径
* 直接失败而不是把错误页当图片送去识别避免白耗百度云额度身份证接口失败也计费</p>
*/
@Component
public class HjcOcrImageFetcher {
/** 连接超时 */
static final int CONNECT_TIMEOUT_MS = 2000;
/** 读取超时:与百度云调用保持同一档 */
static final int READ_TIMEOUT_MS = 8000;
/**
* 取回证件图片字节
*
* @param fileUrl 已上传证件的可访问地址 {@code /api/hjc/auth/upload} {@code /api/file/upload} 返回
* @return 图片原始字节
* @throws HjcOcrException 取图失败地址不可达 200内容不是图片
*/
public byte[] fetch(String fileUrl) {
try (HttpResponse response = HttpRequest.get(fileUrl)
.setConnectionTimeout(CONNECT_TIMEOUT_MS)
.timeout(READ_TIMEOUT_MS)
.execute()) {
if (response.getStatus() != 200) {
throw new HjcOcrException(HjcOcrException.ERROR_IMAGE_FETCH,
"取回证件图片失败:HTTP " + response.getStatus() + " url=" + fileUrl, null);
}
String contentType = response.header("Content-Type");
if (StrUtil.isNotBlank(contentType) && !contentType.toLowerCase().startsWith("image/")) {
throw new HjcOcrException(HjcOcrException.ERROR_IMAGE_FETCH,
"证件地址返回的不是图片:Content-Type=" + contentType + " url=" + fileUrl, null);
}
byte[] bytes = response.bodyBytes();
if (bytes == null || bytes.length == 0) {
throw new HjcOcrException(HjcOcrException.ERROR_IMAGE_FETCH,
"证件地址未取到内容:url=" + fileUrl, null);
}
return bytes;
} catch (HjcOcrException e) {
throw e;
} catch (Exception e) {
throw new HjcOcrException(HjcOcrException.ERROR_IMAGE_FETCH,
"取回证件图片失败:" + e.getMessage() + " url=" + fileUrl, null);
}
}
}
@@ -0,0 +1,45 @@
package com.gxwebsoft.hjc.param;
import com.gxwebsoft.common.core.annotation.QueryField;
import com.gxwebsoft.common.core.annotation.QueryType;
import com.gxwebsoft.common.core.web.BaseParam;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 汇吉采标书项目查询参数
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "HjcBidProjectParam对象", description = "汇吉采标书项目查询参数")
public class HjcBidProjectParam extends BaseParam {
@QueryField(type = QueryType.EQ)
private Integer id;
@QueryField(type = QueryType.EQ)
private String projectNo;
@QueryField(type = QueryType.LIKE)
private String projectName;
@QueryField(type = QueryType.EQ)
private String category;
@QueryField(type = QueryType.EQ)
private Integer status;
@QueryField(type = QueryType.EQ)
private Integer sellingMethod;
@QueryField(type = QueryType.EQ)
private Integer needSell;
@QueryField(type = QueryType.EQ)
private String dataSource;
}
@@ -0,0 +1,30 @@
package com.gxwebsoft.hjc.param;
import com.gxwebsoft.common.core.annotation.QueryField;
import com.gxwebsoft.common.core.annotation.QueryType;
import com.gxwebsoft.common.core.web.BaseParam;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 汇吉采企业资质证件材料查询参数
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "HjcEnterpriseMaterialParam对象", description = "汇吉采企业资质证件材料查询参数")
public class HjcEnterpriseMaterialParam extends BaseParam {
@QueryField(type = QueryType.EQ)
private Integer id;
@QueryField(type = QueryType.EQ)
private Integer enterpriseId;
@QueryField(type = QueryType.EQ)
private String materialType;
}
@@ -0,0 +1,36 @@
package com.gxwebsoft.hjc.param;
import com.gxwebsoft.common.core.annotation.QueryField;
import com.gxwebsoft.common.core.annotation.QueryType;
import com.gxwebsoft.common.core.web.BaseParam;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 汇吉采企业账号与资质查询参数
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "HjcEnterpriseParam对象", description = "汇吉采企业账号与资质查询参数")
public class HjcEnterpriseParam extends BaseParam {
@QueryField(type = QueryType.EQ)
private Integer id;
@QueryField(type = QueryType.EQ)
private Integer userId;
@QueryField(type = QueryType.LIKE)
private String name;
@QueryField(type = QueryType.EQ)
private String creditCode;
@QueryField(type = QueryType.EQ)
private Integer authStatus;
}
@@ -0,0 +1,48 @@
package com.gxwebsoft.hjc.param;
import com.gxwebsoft.common.core.annotation.QueryField;
import com.gxwebsoft.common.core.annotation.QueryType;
import com.gxwebsoft.common.core.web.BaseParam;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 汇吉采标书订单查询参数
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "HjcOrderParam对象", description = "汇吉采标书订单查询参数")
public class HjcOrderParam extends BaseParam {
@QueryField(type = QueryType.EQ)
private Integer id;
@QueryField(type = QueryType.EQ)
private String orderNo;
@QueryField(type = QueryType.EQ)
private Integer projectId;
@QueryField(type = QueryType.EQ)
private String projectNo;
@QueryField(type = QueryType.EQ)
private Integer enterpriseId;
@QueryField(type = QueryType.LIKE)
private String projectName;
@QueryField(type = QueryType.EQ)
private Integer payStatus;
@QueryField(type = QueryType.EQ)
private Integer orderStatus;
@QueryField(type = QueryType.EQ)
private String payMethod;
}
@@ -0,0 +1,33 @@
package com.gxwebsoft.hjc.param;
import com.gxwebsoft.common.core.annotation.QueryField;
import com.gxwebsoft.common.core.annotation.QueryType;
import com.gxwebsoft.common.core.web.BaseParam;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 汇吉采一站式订单推送日志查询参数
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "HjcOrderPushLogParam对象", description = "汇吉采一站式订单推送日志查询参数")
public class HjcOrderPushLogParam extends BaseParam {
@QueryField(type = QueryType.EQ)
private Integer id;
@QueryField(type = QueryType.EQ)
private Integer orderId;
@QueryField(type = QueryType.EQ)
private String orderNo;
@QueryField(type = QueryType.EQ)
private Integer pushStatus;
}
@@ -0,0 +1,36 @@
package com.gxwebsoft.hjc.param;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.gxwebsoft.common.core.annotation.QueryField;
import com.gxwebsoft.common.core.annotation.QueryType;
import com.gxwebsoft.common.core.web.BaseParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 汇吉采密码找回申请查询参数后台列表
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "HjcPasswordApplyParam对象", description = "汇吉采密码找回申请查询参数")
public class HjcPasswordApplyParam extends BaseParam {
@QueryField(type = QueryType.EQ)
private Integer id;
@QueryField(type = QueryType.EQ)
private Integer enterpriseId;
@QueryField(type = QueryType.LIKE)
private String enterpriseName;
@QueryField(type = QueryType.EQ)
private String creditCode;
@QueryField(type = QueryType.EQ)
private Integer status;
}
@@ -0,0 +1,30 @@
package com.gxwebsoft.hjc.param;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.gxwebsoft.common.core.annotation.QueryField;
import com.gxwebsoft.common.core.annotation.QueryType;
import com.gxwebsoft.common.core.web.BaseParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 汇吉采标书项目收藏查询参数
*
* <p><b>{@code enterpriseId} 必须由 controller 在入口处强制覆盖</b>它可以被请求参数绑定
* 若原样透传到查询里就是一个读别家企业收藏的越权入口</p>
*
* @author WebSoft
*/
@Data
@EqualsAndHashCode(callSuper = false)
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "HjcProjectFavoriteParam对象", description = "汇吉采标书项目收藏查询参数")
public class HjcProjectFavoriteParam extends BaseParam {
@QueryField(type = QueryType.EQ)
private Integer enterpriseId;
@QueryField(type = QueryType.EQ)
private Integer projectId;
}
@@ -0,0 +1,21 @@
package com.gxwebsoft.hjc.service;
import com.gxwebsoft.hjc.dto.HjcBannerVo;
import java.util.List;
/**
* 买家端轮播图服务只读
*/
public interface HjcBannerService {
/** 首页轮播的默认展示位置标识 */
String POSITION_HOME_SLIDER = "home_slider";
/**
* 查询启用中的轮播图
*
* @param position 展示位置标识 home_slider为空时取首页轮播位置
*/
List<HjcBannerVo> listEnabled(String position);
}
@@ -0,0 +1,17 @@
package com.gxwebsoft.hjc.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcBidProject;
import com.gxwebsoft.hjc.param.HjcBidProjectParam;
import java.util.List;
public interface HjcBidProjectService extends IService<HjcBidProject> {
PageResult<HjcBidProject> pageRel(HjcBidProjectParam param);
List<HjcBidProject> listRel(HjcBidProjectParam param);
HjcBidProject getByProjectNo(String projectNo);
}
@@ -0,0 +1,51 @@
package com.gxwebsoft.hjc.service;
import com.gxwebsoft.hjc.dto.HjcOneStopProjectPush;
import com.gxwebsoft.hjc.entity.HjcBidProject;
import com.gxwebsoft.hjc.entity.HjcOrder;
public interface HjcBizService {
/**
* 入向一站式推送标书/中标公告 projectNo upsert 标书项目
*/
HjcBidProject upsertFromPush(HjcOneStopProjectPush push, Integer tenantId);
/**
* 出向推送订单到一站式 createPurchaseDetails写推送日志幂等+重试
*/
void pushOrderToOneStop(HjcOrder order);
/**
* 支付成功后标记订单已支付幂等并触发一站式推送
*/
HjcOrder markPaid(String orderNo);
/**
* 退款置已退款并记录退款时间当前时间/退款原因幂等同时推送 REFUNDED 状态到一站式
*
* @param orderNo 订单号
* @param refundReason 退款原因为空时用默认原因
*/
HjcOrder refund(String orderNo, String refundReason);
/**
* 取消未支付订单条件更新把 {@code order_status} 置为 2幂等
*
* <p><b>只做 DB 变更</b>向微信查单与关单都是外部 IO可能几十秒超时
* 放进事务会长时间占着数据库连接与行锁因此由调用方在事务外编排</p>
*
* <p><b>绝不推送一站式</b>出向状态只有 {@code PAID}/{@code REFUNDED}
* {@code buildCreatePurchaseDetails} 对非退款单**恒发** {@code PAID}
* 在取消路径调用 {@code pushOrderToOneStop} 会把一张一分钱没付的单推成已支付采购记录
* 未支付订单从未被推送过推送只在 markPaid / refund 触发所以取消本来也不需要推送</p>
*
* @return 本次是否真的翻转true = 把状态从 0 改成了 2重复调用返回 false
*/
boolean cancelUnpaidOrder(String orderNo);
/**
* 重试失败的推送幂等
*/
void retryPendingPush();
}
@@ -0,0 +1,15 @@
package com.gxwebsoft.hjc.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial;
import com.gxwebsoft.hjc.param.HjcEnterpriseMaterialParam;
import java.util.List;
public interface HjcEnterpriseMaterialService extends IService<HjcEnterpriseMaterial> {
PageResult<HjcEnterpriseMaterial> pageRel(HjcEnterpriseMaterialParam param);
List<HjcEnterpriseMaterial> listRel(HjcEnterpriseMaterialParam param);
}
@@ -0,0 +1,40 @@
package com.gxwebsoft.hjc.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial;
import com.gxwebsoft.hjc.param.HjcEnterpriseParam;
import java.util.List;
public interface HjcEnterpriseService extends IService<HjcEnterprise> {
PageResult<HjcEnterprise> pageRel(HjcEnterpriseParam param);
List<HjcEnterprise> listRel(HjcEnterpriseParam param);
HjcEnterprise getByUserId(Integer userId);
/**
* 企业名称 + 纳税人识别号双要素<b>精确</b>匹配企业档案密码找回的匿名链路用
*
* <p>返回列表而不是单条{@code hjc_enterprise.name} 没有唯一索引命中多条属于脏数据
* 调用方必须看见并拒绝否则会把 A 企业的密码重置落到 B 账号上不能在这里静默取一条</p>
*
* @param tenantId 显式租户匿名请求的租户不能依赖请求头 mapper 注释
*/
List<HjcEnterprise> listByNameAndCreditCode(String name, String creditCode, Integer tenantId);
/**
* 注册时写入企业档案与资质证件同一事务
*
* <p>独立成一个事务方法是因为注册是先在核心实例建号再写汇吉采档案的两步操作
* 两步之间没有事务调用方{@code HjcAuthController.register}需要在第二步失败时
* <b>捕获异常并告知用户账号已建资质未存</b>而不是把它抛成一句操作失败
* 若该方法与调用方处在同一事务里异常必然连带外层回滚也没有机会返回友好文案</p>
*
* @return 企业档案 ID
*/
Integer saveRegistration(HjcEnterprise enterprise, List<HjcEnterpriseMaterial> materials);
}
@@ -0,0 +1,20 @@
package com.gxwebsoft.hjc.service;
import com.gxwebsoft.hjc.dto.HjcOcrResult;
/**
* 汇吉采 证件 OCR 识别百度云 OCR服务端调用
*
* <p>实现见 {@code HjcOcrServiceImpl}供应商细节封装在 {@code com.gxwebsoft.hjc.ocr} 包内</p>
*/
public interface HjcOcrService {
/**
* 识别证件
*
* @param materialType 材料类型idcard_front身份证人像面/ license营业执照
* @param fileUrl 已上传证件的可访问地址
* @return 识别结果不支持的材料类型或识别失败返回 null
*/
HjcOcrResult recognize(String materialType, String fileUrl);
}
@@ -0,0 +1,15 @@
package com.gxwebsoft.hjc.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcOrderPushLog;
import com.gxwebsoft.hjc.param.HjcOrderPushLogParam;
import java.util.List;
public interface HjcOrderPushLogService extends IService<HjcOrderPushLog> {
PageResult<HjcOrderPushLog> pageRel(HjcOrderPushLogParam param);
List<HjcOrderPushLog> listRel(HjcOrderPushLogParam param);
}
@@ -0,0 +1,39 @@
package com.gxwebsoft.hjc.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcOrder;
import com.gxwebsoft.hjc.param.HjcOrderParam;
import java.util.Collection;
import java.util.List;
public interface HjcOrderService extends IService<HjcOrder> {
PageResult<HjcOrder> pageRel(HjcOrderParam param);
List<HjcOrder> listRel(HjcOrderParam param);
HjcOrder getByOrderNo(String orderNo);
/**
* 未支付且未取消的订单置为已取消条件更新幂等
*
* @return 实际影响行数0 1只有把状态从 0 翻到 2 的那一次会返回 1
*/
int cancelPending(String orderNo);
/**
* 统计某标书项目**已付款**的订单数标书详情的购买人数
*/
int countPaidOrders(Integer projectId);
/**
* 该企业已付款过的项目 id 集合我的收藏已购买标记
*
* <p>口径与 {@link #countPaidOrders(Integer)} 一致只看 {@code pay_status = 1}</p>
*
* @param projectIds 非空集合
*/
List<Integer> listPaidProjectIds(Integer enterpriseId, Collection<Integer> projectIds);
}
@@ -0,0 +1,54 @@
package com.gxwebsoft.hjc.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcPasswordApply;
import com.gxwebsoft.hjc.param.HjcPasswordApplyParam;
/**
* 汇吉采密码找回申请材料审核制 ADR-0008
*/
public interface HjcPasswordApplyService extends IService<HjcPasswordApply> {
PageResult<HjcPasswordApply> pageRel(HjcPasswordApplyParam param);
/** 某企业最新一条申请(匿名链路用,显式传租户) */
HjcPasswordApply getLatestByEnterpriseId(Integer enterpriseId, Integer tenantId);
/** 某企业最新一条待审核申请;不为 null 即「重复提交」,直接返回它而不新建 */
HjcPasswordApply getPendingByEnterpriseId(Integer enterpriseId, Integer tenantId);
/** 写入一条待审核申请 */
HjcPasswordApply submit(HjcEnterprise enterprise, String newPassword, String handbookUrl, Integer tenantId);
/**
* 审核仅允许 {@code 0 1}通过待运维重置 {@code 0 2}驳回
*
* <p>带状态条件的更新而不是先查后改避免两个管理员同时点通过/驳回时后者覆盖前者</p>
*
* @return 错误文案成功返回 null
*/
String audit(Integer id, Integer status, String rejectReason, Integer auditUserId);
/**
* 标记已重置仅允许 {@code 1 3}
*
* <p><b>审核通过不等于已重置</b>这一步表示运维已经在核心实例管理后台照申请单执行完毕</p>
*
* @return 错误文案成功返回 null
*/
String markReset(Integer id, Integer resetUserId);
/**
* 频次配额Redis 原子自增键名以 {@code hjc:pwd:} 开头与核心实例的键区分
*
* <p> {@code StringRedisTemplate} 而不是项目的 {@code RedisUtil}后者只包了 set/get/delete
* 没有原子自增"先读再写"的计数在并发下会漏放</p>
*
* @param bucket 配额桶名例如 {@code apply-ip}
* @param subject 配额主体例如客户端 IP 或企业 ID
* @return true = 未超限已占用一次配额
*/
boolean tryAcquireQuota(String bucket, String subject, int limit, long ttlSeconds);
}
@@ -0,0 +1,37 @@
package com.gxwebsoft.hjc.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.dto.HjcFavoriteVo;
import com.gxwebsoft.hjc.entity.HjcProjectFavorite;
import com.gxwebsoft.hjc.param.HjcProjectFavoriteParam;
/**
* 汇吉采标书项目收藏
*/
public interface HjcProjectFavoriteService extends IService<HjcProjectFavorite> {
/**
* 我的收藏分页收藏时间倒序 + 展示态
*
* @param param 必须已由调用方设置 {@code enterpriseId}归属企业 ADR 0011
*/
PageResult<HjcFavoriteVo> pageRel(HjcProjectFavoriteParam param);
/** 该企业是否已收藏该项目。 */
boolean exists(Integer enterpriseId, Integer projectId);
/**
* 收藏**幂等**已收藏时直接返回不改 {@code create_time}
*
* <p>并发下两个请求同时收藏会撞唯一键内部按幂等成功处理不抛异常</p>
*/
void add(Integer enterpriseId, Integer projectId, Integer tenantId);
/**
* 取消收藏**幂等**
*
* @return 实际影响行数0 1 0 也表示成功
*/
int remove(Integer enterpriseId, Integer projectId);
}
@@ -0,0 +1,26 @@
package com.gxwebsoft.hjc.service.impl;
import cn.hutool.core.util.StrUtil;
import com.gxwebsoft.hjc.dto.HjcBannerVo;
import com.gxwebsoft.hjc.mapper.HjcBannerMapper;
import com.gxwebsoft.hjc.service.HjcBannerService;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
/**
* 买家端轮播图服务实现只读 CMS 轮播组
*/
@Service
public class HjcBannerServiceImpl implements HjcBannerService {
@Resource
private HjcBannerMapper hjcBannerMapper;
@Override
public List<HjcBannerVo> listEnabled(String position) {
return hjcBannerMapper.selectEnabledList(
StrUtil.blankToDefault(position, POSITION_HOME_SLIDER));
}
}
@@ -0,0 +1,34 @@
package com.gxwebsoft.hjc.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcBidProject;
import com.gxwebsoft.hjc.mapper.HjcBidProjectMapper;
import com.gxwebsoft.hjc.param.HjcBidProjectParam;
import com.gxwebsoft.hjc.service.HjcBidProjectService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class HjcBidProjectServiceImpl extends ServiceImpl<HjcBidProjectMapper, HjcBidProject> implements HjcBidProjectService {
@Override
public PageResult<HjcBidProject> pageRel(HjcBidProjectParam param) {
PageParam<HjcBidProject, HjcBidProjectParam> page = new PageParam<>(param);
page.setDefaultOrder("id desc");
List<HjcBidProject> list = baseMapper.selectPageRel(page, param);
return new PageResult<>(list, page.getTotal());
}
@Override
public List<HjcBidProject> listRel(HjcBidProjectParam param) {
return baseMapper.selectListRel(param);
}
@Override
public HjcBidProject getByProjectNo(String projectNo) {
return baseMapper.getByProjectNo(projectNo);
}
}
@@ -0,0 +1,300 @@
package com.gxwebsoft.hjc.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.hjc.dto.CreatePurchaseDetails;
import com.gxwebsoft.hjc.dto.HjcOneStopProjectPush;
import com.gxwebsoft.hjc.entity.HjcBidProject;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcOrder;
import com.gxwebsoft.hjc.entity.HjcOrderPushLog;
import com.gxwebsoft.hjc.service.HjcBidProjectService;
import com.gxwebsoft.hjc.service.HjcBizService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import com.gxwebsoft.hjc.service.HjcOrderService;
import com.gxwebsoft.hjc.service.HjcOrderPushLogService;
import com.gxwebsoft.hjc.util.HjcOneStopAuthUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 汇吉采一站式双向对接业务
*/
@Service
public class HjcBizServiceImpl implements HjcBizService {
private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final String STATUS_PAID = "PAID";
private static final String STATUS_REFUNDED = "REFUNDED";
/** 退款原因为空时的兜底值:一站式要求 REFUNDED 必带退款原因,不能推空串 */
private static final String DEFAULT_REFUND_REASON = "管理员操作退款";
/** 退款原因入库/推送长度上限(hjc_order.refund_reason */
private static final int REFUND_REASON_MAX = 255;
@Value("${hjc.one-stop.base-url:}")
private String oneStopBaseUrl;
@Value("${hjc.one-stop.create-purchase-details-path:/api/biz/createPurchaseDetails}")
private String createPurchaseDetailsPath;
@Resource
private HjcBidProjectService hjcBidProjectService;
@Resource
private HjcEnterpriseService hjcEnterpriseService;
@Resource
private HjcOrderService hjcOrderService;
@Resource
private HjcOrderPushLogService hjcOrderPushLogService;
@Resource
private RestTemplate restTemplate;
@Resource
private ObjectMapper objectMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public HjcBidProject upsertFromPush(HjcOneStopProjectPush push, Integer tenantId) {
HjcBidProject project = hjcBidProjectService.getByProjectNo(push.getProjectNo());
boolean isNew = project == null;
if (isNew) {
project = new HjcBidProject();
project.setProjectNo(push.getProjectNo());
project.setTenantId(tenantId);
project.setSaleCount(0);
project.setDataSource("PUSH");
project.setStatus(1);
}
project.setProjectName(push.getProjectName());
project.setTenderer(push.getCustomerName());
project.setWinnerSupplier(push.getSupplierName());
project.setBidAmount(push.getBidAmount());
project.setBulletinTitle(push.getBulletinName());
project.setBulletinContent(push.getContent());
project.setBulletinFileList(push.getFileList());
project.setTenderPrice(push.getTenderPrice());
project.setTenderFile(push.getFiles());
project.setOnsaleTime(parseDateTime(push.getTenderOnsaleTime()));
project.setOffsaleTime(parseDateTime(push.getTenderOffsaleTime()));
// 发布时间=开售时间投标截止=截止时间同一语义直接映射
project.setPublishTime(parseDateTime(push.getTenderOnsaleTime()));
project.setDeadlineTime(parseDateTime(push.getTenderOffsaleTime()));
project.setSellingMethod(push.getSellingMethod());
project.setNeedSell(push.getNeedSellTender());
project.setDataSource("PUSH");
if (isNew) {
hjcBidProjectService.save(project);
} else {
hjcBidProjectService.updateById(project);
}
return project;
}
@Override
public void pushOrderToOneStop(HjcOrder order) {
if (order == null || StrUtil.isBlank(order.getOrderNo())) {
return;
}
HjcOrderPushLog logEntity = new HjcOrderPushLog();
logEntity.setOrderId(order.getId());
logEntity.setOrderNo(order.getOrderNo());
logEntity.setTenantId(order.getTenantId());
logEntity.setPushStatus(0);
logEntity.setAttemptCount(0);
hjcOrderPushLogService.save(logEntity);
doPush(order, logEntity);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void retryPendingPush() {
List<HjcOrderPushLog> pending = hjcOrderPushLogService.list(new LambdaQueryWrapper<HjcOrderPushLog>()
.in(HjcOrderPushLog::getPushStatus, 0, 2)
.and(w -> w.isNull(HjcOrderPushLog::getNextRetryTime)
.or().le(HjcOrderPushLog::getNextRetryTime, LocalDateTime.now()))
.last("limit 50"));
for (HjcOrderPushLog logEntity : pending) {
HjcOrder order = hjcOrderService.getByOrderNo(logEntity.getOrderNo());
if (order != null) {
doPush(order, logEntity);
}
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public HjcOrder markPaid(String orderNo) {
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
if (order == null) {
throw new RuntimeException("订单不存在");
}
if (order.getPayStatus() != null && order.getPayStatus() == 1) {
return order;
}
order.setPayStatus(1);
order.setOrderStatus(1);
order.setPayTime(LocalDateTime.now());
hjcOrderService.updateById(order);
// 支付成功后触发一站式推送
pushOrderToOneStop(order);
return order;
}
@Override
@Transactional(rollbackFor = Exception.class)
public HjcOrder refund(String orderNo, String refundReason) {
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
if (order == null) {
throw new RuntimeException("订单不存在");
}
if (order.getPayStatus() != null && order.getPayStatus() == 3) {
return order;
}
order.setPayStatus(3);
// 退款时间/原因必须落库推送失败重试时按订单重建报文否则重试会丢掉这两个字段
order.setRefundTime(LocalDateTime.now());
order.setRefundReason(StrUtil.isBlank(refundReason) ? DEFAULT_REFUND_REASON : limitStr(refundReason.trim(), REFUND_REASON_MAX));
hjcOrderService.updateById(order);
// 退款后触发一站式推送status=REFUNDED + 退款时间/原因
pushOrderToOneStop(order);
return order;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean cancelUnpaidOrder(String orderNo) {
// 条件更新自带幂等与安全只有把 order_status 0 翻到 2 的那一次会返回 1
// 已支付pay_status=1/3与已取消order_status=2的单都不会被改到
// 这里**不推送一站式**理由见 HjcBizService#cancelUnpaidOrder 的接口注释
return hjcOrderService.cancelPending(orderNo) > 0;
}
private void doPush(HjcOrder order, HjcOrderPushLog logEntity) {
try {
CreatePurchaseDetails body = buildCreatePurchaseDetails(order);
String payload = objectMapper.writeValueAsString(body);
logEntity.setPayload(payload);
if (StrUtil.isBlank(oneStopBaseUrl)) {
logEntity.setAttemptCount(logEntity.getAttemptCount() + 1);
logEntity.setPushStatus(2);
logEntity.setErrorMsg("一站式base-url未配置");
logEntity.setNextRetryTime(LocalDateTime.now().plusMinutes(30));
hjcOrderPushLogService.updateById(logEntity);
return;
}
String ts = HjcOneStopAuthUtil.timestamp();
String sign = HjcOneStopAuthUtil.sign(ts);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("appKey", HjcOneStopAuthUtil.APP_KEY);
headers.set("timestamp", ts);
headers.set("sign", sign);
String url = oneStopBaseUrl + createPurchaseDetailsPath;
ResponseEntity<String> resp = restTemplate.postForEntity(url, new HttpEntity<>(payload, headers), String.class);
logEntity.setPayload(payload);
logEntity.setAttemptCount(logEntity.getAttemptCount() + 1);
logEntity.setHttpCode(resp.getStatusCode().value());
logEntity.setResponseBody(limitStr(resp.getBody(), 3000));
if (resp.getStatusCode().is2xxSuccessful()) {
logEntity.setPushStatus(1);
logEntity.setErrorMsg(null);
logEntity.setNextRetryTime(null);
} else {
logEntity.setPushStatus(2);
logEntity.setErrorMsg("HTTP " + resp.getStatusCode().value());
logEntity.setNextRetryTime(LocalDateTime.now().plusMinutes(30));
}
} catch (Exception e) {
logEntity.setAttemptCount(logEntity.getAttemptCount() + 1);
logEntity.setPushStatus(2);
logEntity.setErrorMsg(limitStr(e.getMessage(), 1000));
logEntity.setNextRetryTime(LocalDateTime.now().plusMinutes(30));
}
hjcOrderPushLogService.updateById(logEntity);
}
private CreatePurchaseDetails buildCreatePurchaseDetails(HjcOrder order) {
CreatePurchaseDetails body = new CreatePurchaseDetails();
body.setIdempotencyKey("HJC_ORD_" + order.getOrderNo());
body.setOrderNo(order.getOrderNo());
body.setProjectNo(order.getProjectNo());
body.setProjectName(order.getProjectName());
body.setTenderPrice(order.getUnitPrice());
body.setQuantity(order.getQuantity());
body.setTotalAmount(order.getTotalAmount());
body.setPaidAt(order.getPayTime() == null ? null : order.getPayTime().format(DT_FMT));
body.setPayMethod(order.getPayMethod());
boolean refunded = order.getPayStatus() != null && order.getPayStatus() == 3;
body.setStatus(refunded ? STATUS_REFUNDED : STATUS_PAID);
// 一站式要求退款推送必带退款时间与退款原因非退款单不推这两个字段
if (refunded) {
LocalDateTime refundTime = order.getRefundTime() != null ? order.getRefundTime() : order.getUpdateTime();
body.setRefundTime(refundTime == null ? null : refundTime.format(DT_FMT));
body.setRefundReason(StrUtil.isBlank(order.getRefundReason()) ? DEFAULT_REFUND_REASON : order.getRefundReason());
}
body.setInvoiceStatus(invoiceStatus(order.getInvoiceStatus()));
CreatePurchaseDetails.Buyer buyer = new CreatePurchaseDetails.Buyer();
buyer.setEnterpriseName(order.getEnterpriseName());
buyer.setContactName(order.getContactName());
buyer.setContactPhone(order.getContactPhone());
buyer.setContactEmail(order.getContactEmail());
if (order.getEnterpriseId() != null) {
HjcEnterprise enterprise = hjcEnterpriseService.getById(order.getEnterpriseId());
if (enterprise != null) {
buyer.setCreditCode(enterprise.getCreditCode());
}
}
body.setBuyer(buyer);
return body;
}
private String invoiceStatus(Integer invoiceStatus) {
if (invoiceStatus == null) {
return "NONE";
}
switch (invoiceStatus) {
case 1: return "APPLIED";
case 2: return "ISSUED";
default: return "NONE";
}
}
private LocalDateTime parseDateTime(String text) {
if (StrUtil.isBlank(text)) {
return null;
}
String t = text.trim();
try {
if (t.length() == 14) {
return LocalDateTime.parse(t, DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
}
if (t.contains("T")) {
return LocalDateTime.parse(t);
}
return LocalDateTime.parse(t, DT_FMT);
} catch (Exception e) {
return null;
}
}
private String limitStr(String s, int max) {
if (s == null) {
return null;
}
return s.length() > max ? s.substring(0, max) : s;
}
}
@@ -0,0 +1,29 @@
package com.gxwebsoft.hjc.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial;
import com.gxwebsoft.hjc.mapper.HjcEnterpriseMaterialMapper;
import com.gxwebsoft.hjc.param.HjcEnterpriseMaterialParam;
import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class HjcEnterpriseMaterialServiceImpl extends ServiceImpl<HjcEnterpriseMaterialMapper, HjcEnterpriseMaterial> implements HjcEnterpriseMaterialService {
@Override
public PageResult<HjcEnterpriseMaterial> pageRel(HjcEnterpriseMaterialParam param) {
PageParam<HjcEnterpriseMaterial, HjcEnterpriseMaterialParam> page = new PageParam<>(param);
page.setDefaultOrder("id desc");
List<HjcEnterpriseMaterial> list = baseMapper.selectPageRel(page, param);
return new PageResult<>(list, page.getTotal());
}
@Override
public List<HjcEnterpriseMaterial> listRel(HjcEnterpriseMaterialParam param) {
return baseMapper.selectListRel(param);
}
}
@@ -0,0 +1,67 @@
package com.gxwebsoft.hjc.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial;
import com.gxwebsoft.hjc.mapper.HjcEnterpriseMapper;
import com.gxwebsoft.hjc.param.HjcEnterpriseParam;
import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.List;
@Service
public class HjcEnterpriseServiceImpl extends ServiceImpl<HjcEnterpriseMapper, HjcEnterprise> implements HjcEnterpriseService {
@Resource
private HjcEnterpriseMaterialService hjcEnterpriseMaterialService;
@Override
public PageResult<HjcEnterprise> pageRel(HjcEnterpriseParam param) {
PageParam<HjcEnterprise, HjcEnterpriseParam> page = new PageParam<>(param);
page.setDefaultOrder("id desc");
List<HjcEnterprise> list = baseMapper.selectPageRel(page, param);
return new PageResult<>(list, page.getTotal());
}
@Override
public List<HjcEnterprise> listRel(HjcEnterpriseParam param) {
return baseMapper.selectListRel(param);
}
@Override
public HjcEnterprise getByUserId(Integer userId) {
return baseMapper.getByUserId(userId);
}
@Override
public List<HjcEnterprise> listByNameAndCreditCode(String name, String creditCode, Integer tenantId) {
return baseMapper.getByNameAndCreditCode(name, creditCode, tenantId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Integer saveRegistration(HjcEnterprise enterprise, List<HjcEnterpriseMaterial> materials) {
baseMapper.insert(enterprise);
if (materials != null) {
for (HjcEnterpriseMaterial m : materials) {
if (StrUtil.isBlank(m.getFileUrl())) {
continue;
}
m.setId(null);
m.setEnterpriseId(enterprise.getId());
if (m.getTenantId() == null) {
m.setTenantId(enterprise.getTenantId());
}
hjcEnterpriseMaterialService.save(m);
}
}
return enterprise.getId();
}
}
@@ -0,0 +1,203 @@
package com.gxwebsoft.hjc.service.impl;
import cn.hutool.core.util.StrUtil;
import com.gxwebsoft.hjc.dto.HjcOcrResult;
import com.gxwebsoft.hjc.ocr.HjcBaiduOcrClient;
import com.gxwebsoft.hjc.ocr.HjcOcrException;
import com.gxwebsoft.hjc.ocr.HjcOcrImageFetcher;
import com.gxwebsoft.hjc.service.HjcOcrService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.io.ByteArrayInputStream;
import java.util.Iterator;
import java.util.Map;
/**
* 汇吉采 证件 OCR 识别实现百度云 OCR
*
* <p>只做业务映射这一件事材料类型路由 取图与前置校验 调百度云 中文 key 映射为
* {@link HjcOcrResult} 字段 过滤未识别哨兵值HTTP 细节在 {@link HjcBaiduOcrClient}
* 取图在 {@link HjcOcrImageFetcher}两层都可独立打桩测试</p>
*
* <p><b>不阻断原则</b>未配置凭据材料类型无需识别图片不合规百度云返回错误码一律返回 {@code null}
* Controller 统一回识别失败或该类型无需识别请手动填写注册流程照常可提交</p>
*
* <p><b>日志</b>只记录材料类型图片字节数识别到的字段名错误码与 log_id
* <b>不记录识别出的值</b>姓名身份证号属个人信息</p>
*/
@Slf4j
@Service
public class HjcOcrServiceImpl implements HjcOcrService {
/** 材料类型:身份证人像面 */
public static final String MATERIAL_IDCARD_FRONT = "idcard_front";
/** 材料类型:营业执照 */
public static final String MATERIAL_LICENSE = "license";
/** 百度云对未识别字段返回的哨兵文本(location 全为 -1/0),按「未识别」处理 */
static final String UNRECOGNIZED_TEXT = "";
/** 百度云 words_result key:身份证人像面 */
static final String KEY_NAME = "姓名";
/** 身份证号(百度云为「公民身份号码」,不是「身份证号」) */
static final String KEY_ID_NUMBER = "公民身份号码";
/** 营业执照 words_result key:企业名称 */
static final String KEY_COMPANY_NAME = "单位名称";
/** 统一社会信用代码(百度云的 key 是「社会信用代码」,取「统一社会信用代码」会静默取空) */
static final String KEY_CREDIT_CODE = "社会信用代码";
/** 企业地址 */
static final String KEY_ADDRESS = "地址";
/** 法定代表人 */
static final String KEY_LEGAL_PERSON = "法人";
/**
* 图片大小上限原始字节身份证 8M / 营业执照 10M按百度云接口页
*
* <p>百度云调用方式页另称全接口 4M与接口页冲突此处按较宽松的接口页取值
* 不因一次未经验证的文档冲突就拦掉前端 5M 上限的既有照片实测若证实为 4M
* 再决定砍前端上限或加服务端压缩</p>
*/
static final long MAX_IMAGE_BYTES_IDCARD = 8L * 1024 * 1024;
static final long MAX_IMAGE_BYTES_LICENSE = 10L * 1024 * 1024;
/** 图片最短边下限(px */
static final int MIN_SIDE_PX = 15;
@Resource
private HjcBaiduOcrClient baiduOcrClient;
@Resource
private HjcOcrImageFetcher imageFetcher;
@Override
public HjcOcrResult recognize(String materialType, String fileUrl) {
if (StrUtil.isBlank(materialType) || StrUtil.isBlank(fileUrl)) {
return null;
}
boolean idCardFront = MATERIAL_IDCARD_FRONT.equals(materialType);
boolean license = MATERIAL_LICENSE.equals(materialType);
if (!idCardFront && !license) {
// idcard_back身份证国徽面 handbook授权委托书只上传不识别
log.info("HjcOcr: 材料类型 {} 无需识别,跳过", materialType);
return null;
}
if (!baiduOcrClient.isConfigured()) {
// 占位未填不算错误注册流程不受影响
log.warn("HjcOcr: baidu.ocr.api-key/secret-key 未配置,跳过识别 materialType={}", materialType);
return null;
}
try {
byte[] imageBytes = imageFetcher.fetch(fileUrl);
long maxBytes = idCardFront ? MAX_IMAGE_BYTES_IDCARD : MAX_IMAGE_BYTES_LICENSE;
if (imageBytes.length > maxBytes) {
throw new HjcOcrException(HjcOcrException.ERROR_IMAGE_TOO_LARGE,
"图片超过接口上限,未调用百度云:bytes=" + imageBytes.length + " limit=" + maxBytes, null);
}
if (!hasEnoughSideLength(imageBytes, MIN_SIDE_PX)) {
throw new HjcOcrException(HjcOcrException.ERROR_IMAGE_TOO_SMALL,
"图片最短边小于 " + MIN_SIDE_PX + "px,未调用百度云", null);
}
HjcBaiduOcrClient.Result recognized = idCardFront
? baiduOcrClient.recognizeIdCardFront(imageBytes)
: baiduOcrClient.recognizeBusinessLicense(imageBytes);
HjcOcrResult result = idCardFront ? toIdCardResult(recognized) : toLicenseResult(recognized);
if (result == null) {
// 常见于身份证正反面传错image_status=reversed_side或照片质量差
throw new HjcOcrException(HjcOcrException.ERROR_NO_FIELD,
"未识别到任何字段,imageStatus=" + recognized.getImageStatus(), recognized.getLogId());
}
log.info("HjcOcr: 识别完成 materialType={} bytes={} words={} imageStatus={} logId={}",
materialType, imageBytes.length, recognized.getWords().keySet(),
recognized.getImageStatus(), recognized.getLogId());
return result;
} catch (HjcOcrException e) {
log.warn("HjcOcr: 识别失败 materialType={} errorCode={} fromBaidu={} logId={} msg={}",
materialType, e.getErrorCode(), e.isBaiduError(), e.getLogId(), e.getMessage());
} catch (Exception e) {
log.warn("HjcOcr: 识别异常 materialType={}", materialType, e);
}
return null;
}
/** 身份证人像面:回填经办人姓名 + 身份证号;一个都没识别到则返回 null(视为识别失败) */
private HjcOcrResult toIdCardResult(HjcBaiduOcrClient.Result recognized) {
HjcOcrResult result = new HjcOcrResult();
result.setMaterialType(MATERIAL_IDCARD_FRONT);
result.setAgentName(text(recognized.getWords(), KEY_NAME));
result.setIdCardNo(text(recognized.getWords(), KEY_ID_NUMBER));
if (result.getAgentName() == null && result.getIdCardNo() == null) {
return null;
}
return result;
}
/**
* 营业执照回填统一社会信用代码 + 企业地址企业名称与法定代表人仅作展示提示不覆盖登录账号
* 老版执照可能没有社会信用代码缺失即不回填由用户手填
*/
private HjcOcrResult toLicenseResult(HjcBaiduOcrClient.Result recognized) {
Map<String, String> words = recognized.getWords();
HjcOcrResult result = new HjcOcrResult();
result.setMaterialType(MATERIAL_LICENSE);
result.setEnterpriseName(text(words, KEY_COMPANY_NAME));
result.setCreditCode(text(words, KEY_CREDIT_CODE));
result.setAddress(text(words, KEY_ADDRESS));
result.setLegalPerson(text(words, KEY_LEGAL_PERSON));
if (result.getEnterpriseName() == null && result.getCreditCode() == null
&& result.getAddress() == null && result.getLegalPerson() == null) {
return null;
}
return result;
}
/**
* 取字段文本{@code null}空串trim 后等于一律视为未识别
*
* <p>百度云对未识别字段不返回空值而是返回不过滤就会被前端
* {@code fillIfBlank} 当成有效值填进表单</p>
*/
static String text(Map<String, String> words, String key) {
if (words == null) {
return null;
}
String value = words.get(key);
if (value == null) {
return null;
}
String trimmed = value.trim();
if (trimmed.isEmpty() || UNRECOGNIZED_TEXT.equals(trimmed)) {
return null;
}
return trimmed;
}
/**
* 最短边是否达标只读图片元数据不解码整图读不出尺寸时放行交给百度云判断
*/
private static boolean hasEnoughSideLength(byte[] imageBytes, int minSide) {
try (ImageInputStream input = ImageIO.createImageInputStream(new ByteArrayInputStream(imageBytes))) {
if (input == null) {
return true;
}
Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
if (!readers.hasNext()) {
return true;
}
ImageReader reader = readers.next();
try {
reader.setInput(input);
return reader.getWidth(0) >= minSide && reader.getHeight(0) >= minSide;
} finally {
reader.dispose();
}
} catch (Exception e) {
log.debug("HjcOcr: 读取图片尺寸失败,跳过最短边校验", e);
return true;
}
}
}
@@ -0,0 +1,29 @@
package com.gxwebsoft.hjc.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcOrderPushLog;
import com.gxwebsoft.hjc.mapper.HjcOrderPushLogMapper;
import com.gxwebsoft.hjc.param.HjcOrderPushLogParam;
import com.gxwebsoft.hjc.service.HjcOrderPushLogService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class HjcOrderPushLogServiceImpl extends ServiceImpl<HjcOrderPushLogMapper, HjcOrderPushLog> implements HjcOrderPushLogService {
@Override
public PageResult<HjcOrderPushLog> pageRel(HjcOrderPushLogParam param) {
PageParam<HjcOrderPushLog, HjcOrderPushLogParam> page = new PageParam<>(param);
page.setDefaultOrder("id desc");
List<HjcOrderPushLog> list = baseMapper.selectPageRel(page, param);
return new PageResult<>(list, page.getTotal());
}
@Override
public List<HjcOrderPushLog> listRel(HjcOrderPushLogParam param) {
return baseMapper.selectListRel(param);
}
}
@@ -0,0 +1,58 @@
package com.gxwebsoft.hjc.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcOrder;
import com.gxwebsoft.hjc.mapper.HjcOrderMapper;
import com.gxwebsoft.hjc.param.HjcOrderParam;
import com.gxwebsoft.hjc.service.HjcOrderService;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
@Service
public class HjcOrderServiceImpl extends ServiceImpl<HjcOrderMapper, HjcOrder> implements HjcOrderService {
@Override
public PageResult<HjcOrder> pageRel(HjcOrderParam param) {
PageParam<HjcOrder, HjcOrderParam> page = new PageParam<>(param);
page.setDefaultOrder("id desc");
List<HjcOrder> list = baseMapper.selectPageRel(page, param);
return new PageResult<>(list, page.getTotal());
}
@Override
public List<HjcOrder> listRel(HjcOrderParam param) {
return baseMapper.selectListRel(param);
}
@Override
public HjcOrder getByOrderNo(String orderNo) {
return baseMapper.getByOrderNo(orderNo);
}
@Override
public int cancelPending(String orderNo) {
return baseMapper.cancelPending(orderNo);
}
@Override
public int countPaidOrders(Integer projectId) {
if (projectId == null) {
return 0;
}
return baseMapper.countPaidOrders(projectId);
}
@Override
public List<Integer> listPaidProjectIds(Integer enterpriseId, Collection<Integer> projectIds) {
if (enterpriseId == null || projectIds == null || projectIds.isEmpty()) {
// 空集合交给 SQL 会生成 `IN ()`那是语法错误在这里就地短路
return new ArrayList<>();
}
return baseMapper.listPaidProjectIds(enterpriseId, projectIds);
}
}
@@ -0,0 +1,164 @@
package com.gxwebsoft.hjc.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcPasswordApply;
import com.gxwebsoft.hjc.mapper.HjcPasswordApplyMapper;
import com.gxwebsoft.hjc.param.HjcPasswordApplyParam;
import com.gxwebsoft.hjc.service.HjcPasswordApplyService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class HjcPasswordApplyServiceImpl extends ServiceImpl<HjcPasswordApplyMapper, HjcPasswordApply>
implements HjcPasswordApplyService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public PageResult<HjcPasswordApply> pageRel(HjcPasswordApplyParam param) {
PageParam<HjcPasswordApply, HjcPasswordApplyParam> page = new PageParam<>(param);
page.setDefaultOrder("id desc");
List<HjcPasswordApply> list = baseMapper.selectPageRel(page, param);
return new PageResult<>(list, page.getTotal());
}
@Override
public HjcPasswordApply getLatestByEnterpriseId(Integer enterpriseId, Integer tenantId) {
return baseMapper.getLatestByEnterpriseId(enterpriseId, tenantId);
}
@Override
public HjcPasswordApply getPendingByEnterpriseId(Integer enterpriseId, Integer tenantId) {
return baseMapper.getPendingByEnterpriseId(enterpriseId, tenantId);
}
@Override
public HjcPasswordApply submit(HjcEnterprise enterprise, String newPassword, String handbookUrl, Integer tenantId) {
HjcPasswordApply apply = new HjcPasswordApply();
apply.setEnterpriseId(enterprise.getId());
apply.setUserId(enterprise.getUserId());
// 存提交时的快照企业档案事后可能被改申请单不随之漂移
apply.setEnterpriseName(enterprise.getName());
apply.setCreditCode(enterprise.getCreditCode());
apply.setNewPassword(newPassword);
apply.setHandbookUrl(handbookUrl);
apply.setStatus(HjcPasswordApply.STATUS_PENDING);
apply.setTenantId(tenantId);
save(apply);
return apply;
}
@Override
public String audit(Integer id, Integer status, String rejectReason, Integer auditUserId) {
if (id == null || status == null) {
return "审核参数不完整";
}
if (status != HjcPasswordApply.STATUS_APPROVED && status != HjcPasswordApply.STATUS_REJECTED) {
return "审核结果不合法";
}
if (status == HjcPasswordApply.STATUS_REJECTED && StrUtil.isBlank(rejectReason)) {
return "驳回必须填写原因";
}
LambdaUpdateWrapper<HjcPasswordApply> wrapper = new LambdaUpdateWrapper<HjcPasswordApply>()
.eq(HjcPasswordApply::getId, id)
.eq(HjcPasswordApply::getStatus, HjcPasswordApply.STATUS_PENDING)
.set(HjcPasswordApply::getStatus, status)
.set(HjcPasswordApply::getAuditUserId, auditUserId)
.set(HjcPasswordApply::getAuditTime, LocalDateTime.now());
if (status == HjcPasswordApply.STATUS_REJECTED) {
wrapper.set(HjcPasswordApply::getRejectReason, rejectReason);
}
if (!update(wrapper)) {
// 条件更新影响 0 要么单不存在要么已被别人处理过不是待审核
HjcPasswordApply exist = getById(id);
if (exist == null) {
return "申请不存在";
}
return "该申请当前状态为「" + statusText(exist.getStatus()) + "」,不能重复审核";
}
return null;
}
@Override
public String markReset(Integer id, Integer resetUserId) {
if (id == null) {
return "参数不完整";
}
boolean updated = update(new LambdaUpdateWrapper<HjcPasswordApply>()
.eq(HjcPasswordApply::getId, id)
.eq(HjcPasswordApply::getStatus, HjcPasswordApply.STATUS_APPROVED)
.set(HjcPasswordApply::getStatus, HjcPasswordApply.STATUS_RESET)
.set(HjcPasswordApply::getResetUserId, resetUserId)
.set(HjcPasswordApply::getResetTime, LocalDateTime.now()));
if (!updated) {
HjcPasswordApply exist = getById(id);
if (exist == null) {
return "申请不存在";
}
if (Integer.valueOf(HjcPasswordApply.STATUS_RESET).equals(exist.getStatus())) {
return "该申请已标记为已重置";
}
return "只有「已通过(待重置)」的申请才能标记为已重置,当前状态为「" + statusText(exist.getStatus()) + "";
}
return null;
}
@Override
public boolean tryAcquireQuota(String bucket, String subject, int limit, long ttlSeconds) {
if (StrUtil.isBlank(subject)) {
// 取不到主体例如拿不到客户端 IP时不拦宁可少一道限制也不要误伤正常用户
return true;
}
String key = "hjc:pwd:" + bucket + ":" + subject;
try {
Long count = stringRedisTemplate.opsForValue().increment(key);
if (count == null) {
return true;
}
if (count == 1L) {
stringRedisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS);
} else {
Long ttl = stringRedisTemplate.getExpire(key);
if (ttl == null || ttl < 0) {
// 兜底极端情况下自增成功但设置有效期失败补一次 TTL避免该主体被永久卡死
stringRedisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS);
}
}
return count <= limit;
} catch (Exception e) {
log.error("HjcPassword: 频次限制读写失败,本次放行 key={}", key, e);
return true;
}
}
private static String statusText(Integer status) {
if (status == null) {
return "未知";
}
switch (status) {
case HjcPasswordApply.STATUS_PENDING:
return "待审核";
case HjcPasswordApply.STATUS_APPROVED:
return "已通过(待重置)";
case HjcPasswordApply.STATUS_REJECTED:
return "已驳回";
case HjcPasswordApply.STATUS_RESET:
return "已重置";
default:
return "未知";
}
}
}
@@ -0,0 +1,141 @@
package com.gxwebsoft.hjc.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.dto.HjcFavoriteVo;
import com.gxwebsoft.hjc.entity.HjcProjectFavorite;
import com.gxwebsoft.hjc.mapper.HjcProjectFavoriteMapper;
import com.gxwebsoft.hjc.param.HjcProjectFavoriteParam;
import com.gxwebsoft.hjc.service.HjcOrderService;
import com.gxwebsoft.hjc.service.HjcProjectFavoriteService;
import com.gxwebsoft.hjc.util.HjcFavoriteStateUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 汇吉采标书项目收藏
*/
@Slf4j
@Service
public class HjcProjectFavoriteServiceImpl extends ServiceImpl<HjcProjectFavoriteMapper, HjcProjectFavorite>
implements HjcProjectFavoriteService {
@Resource
private HjcOrderService hjcOrderService;
@Override
public PageResult<HjcFavoriteVo> pageRel(HjcProjectFavoriteParam param) {
PageParam<HjcFavoriteVo, HjcProjectFavoriteParam> page = new PageParam<>(param);
// 收藏列表**不支持客户端排序**这是产品口径spec 决策 6
// 放行客户端排序会真的出事这个查询是连表`tenant_id` `update_time` 两张表都有
// 又都没被选进选择列表客户端传 `?sort=tenantId` / `?sort=updateTime` 会直接得到
// `Column 'tenant_id' in order clause is ambiguous` 500**已在 dev 库实测**
// .scratch/hjc-favorite/issues/08-验收.md 的实测记录
// 而传 `?sort=id` 更隐蔽**它不报错**`id` 在选择列表里MySQL 会按输出列名解析
// 只是把收藏时间倒序悄悄换成按 a.id
// 所以这里是**覆盖**先把 orders 清空而不是 setDefaultOrder
// 后者只在 orders 为空时才生效PageParam#setDefaultOrder
page.setOrders(new ArrayList<>());
page.setDefaultOrder("a.create_time desc");
List<HjcFavoriteVo> list = baseMapper.selectPageRel(page, param);
fillSaleState(list);
fillPurchased(list, param == null ? null : param.getEnterpriseId());
return new PageResult<>(list, page.getTotal());
}
@Override
public boolean exists(Integer enterpriseId, Integer projectId) {
if (enterpriseId == null || projectId == null) {
return false;
}
// 不写 XMLwrapper 查询由租户拦截器自动附加 tenant_id且本实体没有 @TableLogic
return baseMapper.selectCount(new LambdaQueryWrapper<HjcProjectFavorite>()
.eq(HjcProjectFavorite::getEnterpriseId, enterpriseId)
.eq(HjcProjectFavorite::getProjectId, projectId)) > 0;
}
@Override
public void add(Integer enterpriseId, Integer projectId, Integer tenantId) {
// 幂等已收藏就直接成功**不改 create_time**按收藏时间倒序的语义是
// 最近一次收藏排最前重复点击不该把它顶到最上面spec 决策 16
if (exists(enterpriseId, projectId)) {
return;
}
HjcProjectFavorite favorite = new HjcProjectFavorite();
favorite.setEnterpriseId(enterpriseId);
favorite.setProjectId(projectId);
favorite.setTenantId(tenantId);
try {
baseMapper.insert(favorite);
} catch (DuplicateKeyException e) {
// 并发下两个请求同时收藏唯一键 uk_hjc_pf_ent_project 挡掉第二个
// 幂等是接口契约spec 决策 16所以这里**吞掉异常视为成功**不要让它冒成 500
log.info("HjcProjectFavorite: 重复收藏(并发命中唯一键),按幂等成功处理 enterpriseId={} projectId={}",
enterpriseId, projectId);
}
}
@Override
public int remove(Integer enterpriseId, Integer projectId) {
if (enterpriseId == null || projectId == null) {
return 0;
}
// 没有 @TableLogic所以这是一条真 DELETEADR 0012本来没收藏就影响 0 也算成功
return baseMapper.delete(new LambdaQueryWrapper<HjcProjectFavorite>()
.eq(HjcProjectFavorite::getEnterpriseId, enterpriseId)
.eq(HjcProjectFavorite::getProjectId, projectId));
}
/** 填充展示态。判定逻辑在 {@link HjcFavoriteStateUtil}(纯函数,可单测)。 */
private void fillSaleState(List<HjcFavoriteVo> list) {
if (list == null || list.isEmpty()) {
return;
}
// 一次分页共用一个基准时间避免同一页里两条相差几毫秒却落在停售时间两侧
LocalDateTime now = LocalDateTime.now();
for (HjcFavoriteVo vo : list) {
vo.setSaleState(HjcFavoriteStateUtil.saleStateOf(vo, now));
}
}
/**
* 填充本企业是否已购买该项目
*
* <p><b>为什么不用 JOIN</b>一个项目可能有多张订单{@code LEFT JOIN hjc_order} 会把收藏行
* 复制成多行分页的行数与总数会一起算错所以分页查完之后用当页的 projectId 再发**一条**
* {@code IN} 查询在内存里打标</p>
*
* <p>口径复用详情页购买人数那一条{@code HjcOrderService#countPaidProjectIds} 的实现
* <b>已付款</b>退款单与未付款单都不算</p>
*/
private void fillPurchased(List<HjcFavoriteVo> list, Integer enterpriseId) {
if (list == null || list.isEmpty() || enterpriseId == null) {
return;
}
Set<Integer> projectIds = list.stream()
.map(HjcFavoriteVo::getProjectId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (projectIds.isEmpty()) {
return;
}
// 当页非空时 IN 一定有内容不会出现 `IN ()` 的语法错误
Set<Integer> paidProjectIds = new HashSet<>(
hjcOrderService.listPaidProjectIds(enterpriseId, projectIds));
for (HjcFavoriteVo vo : list) {
vo.setPurchased(vo.getProjectId() != null && paidProjectIds.contains(vo.getProjectId()));
}
}
}
@@ -0,0 +1,77 @@
package com.gxwebsoft.hjc.util;
import com.gxwebsoft.hjc.dto.HjcFavoriteVo;
import java.time.LocalDateTime;
import java.util.Objects;
/**
* 我的收藏展示态的判定逻辑纯函数不依赖 Spring
*
* <p><b>为什么单独抽出来</b>这一支有三个输入项目是否还在 / 项目状态 / 停售时间
* 项目已被物理删除但收藏行还在这种数据端到端造起来很麻烦只能靠单测钉住判定本身</p>
*
* <p><b>为什么判定在后端做</b>口径只写一份仓库里 {@code orderStatus} 的口径在
* {@code hjc-h5} {@code hjc-web} 各写了一份改动时必然分叉收藏不再重演这件事</p>
*/
public final class HjcFavoriteStateUtil {
private HjcFavoriteStateUtil() {
}
/** 在售:可进详情。 */
public static final String ONSALE = "onsale";
/** 已结束:项目还在架,但停售时间已过(自然到期)。 */
public static final String ENDED = "ended";
/** 已下架:项目被后台下架,或已被删除(平台行为)。 */
public static final String REMOVED = "removed";
/** {@code hjc_bid_project.status}0 = 下架/停售。 */
private static final int PROJECT_STATUS_OFF = 0;
/** {@code hjc_bid_project.deleted}1 = 已删除。 */
private static final int DELETED_YES = 1;
/**
* 判定一条收藏的展示态
*
* <p>优先级<b>已下架 &gt; 已结束 &gt; 在售</b>项目已被删除时即便它的 {@code status}
* 仍是 1也必须是已下架项目还在不在比它的状态字段更根本</p>
*
* @param vo 收藏条目 {@code joinedProjectId} / {@code projectDeleted} /
* {@code projectStatus} / {@code offsaleTime}
* @param now 判定基准时间**由调用方传入**而不是在方法内部取
* {@code LocalDateTime.now()}否则边界用例没法测
* @return {@link #ONSALE} / {@link #ENDED} / {@link #REMOVED}
*/
public static String saleStateOf(HjcFavoriteVo vo, LocalDateTime now) {
if (vo == null) {
return REMOVED;
}
// 项目已被物理删除LEFT JOIN 没匹配上收藏行还在项目没了
if (vo.getJoinedProjectId() == null) {
return REMOVED;
}
// 项目被逻辑删除或被后台下架
if (Objects.equals(vo.getProjectDeleted(), DELETED_YES)
|| Objects.equals(vo.getProjectStatus(), PROJECT_STATUS_OFF)) {
return REMOVED;
}
// 项目在架但停售时间已过 自然到期
if (vo.getOffsaleTime() != null && now != null && !vo.getOffsaleTime().isAfter(now)) {
return ENDED;
}
return ONSALE;
}
/**
* 该条目是否还能进入详情页
*
* <p>与前端失效项目不进详情只保留取消收藏一一对应spec 决策 8</p>
*/
public static boolean enterable(String saleState) {
return ONSALE.equals(saleState);
}
}
@@ -0,0 +1,53 @@
package com.gxwebsoft.hjc.util;
import cn.hutool.crypto.SecureUtil;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 汇吉采 一站式平台 推送鉴权工具
* 规则sign = MD5(appKey + password + timestamp)timestamp = yyyyMMddHHmm北京时
* 详见 mp-java/docs/一站式平台推送-接口鉴权规范.md
*/
public final class HjcOneStopAuthUtil {
public static final String APP_KEY = "HJC_Official_Website";
public static final String PASSWORD = "vQ8$kR3#mW6@xP2!nF";
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmm");
private HjcOneStopAuthUtil() {
}
/** 当前北京时间,yyyyMMddHHmm */
public static String timestamp() {
return LocalDateTime.now().format(FMT);
}
/** 计算签名 sign = MD5(appKey + password + timestamp),小写 32 位 */
public static String sign(String timestamp) {
return SecureUtil.md5(APP_KEY + PASSWORD + timestamp);
}
/** 校验请求头中的 sign 是否符合 appKey/password/timestamp 约定 */
public static boolean verify(String appKey, String timestamp, String sign) {
if (appKey == null || timestamp == null || sign == null) {
return false;
}
if (!APP_KEY.equals(appKey)) {
return false;
}
// 时间窗口 ±10 分钟防重放
LocalDateTime ts;
try {
ts = LocalDateTime.parse(timestamp, FMT);
} catch (Exception e) {
return false;
}
if (Math.abs(java.time.Duration.between(ts, LocalDateTime.now()).toMinutes()) > 10) {
return false;
}
return sign.equalsIgnoreCase(sign(timestamp));
}
}
@@ -0,0 +1,91 @@
package com.gxwebsoft.hjc.util;
import java.util.Objects;
/**
* 取消订单的判定逻辑纯函数不依赖 Spring
*
* <p><b>为什么单独抽出来</b>本功能里最危险的一支是其实已付款却被买家点了取消
* 要挡住它唯一的时机是**取消之前向微信查单**而微信说 SUCCESS 这一支在 dev 环境里
* <b>造不出来</b>要真付一笔钱只能靠单测钉住判定本身所以判定必须是不依赖 Spring 的静态方法</p>
*
* <p><b>为什么放两个"已支付"出口</b>本地 {@code pay_status == 1} 是已落库的事实
* 不必也不应再去打微信微信侧 SUCCESS "本地还没落、但钱已经收了"除了拒绝取消
* 调用方还要顺手调 {@code markPaid} 把本地补齐幂等两者结论相同后续动作不同
* 所以调用方拿到的是同一个 {@link Decision#ALREADY_PAID}由它自己按本地状态决定要不要自愈</p>
*/
public final class HjcOrderCancelUtil {
private HjcOrderCancelUtil() {
}
/** 微信侧「已收款」的状态名({@code PaymentStatus.SUCCESS})。 */
public static final String WECHAT_STATUS_SUCCESS = "SUCCESS";
/** 订单状态:2 = 已取消(见 {@code hjc_order.order_status} 的列注释)。 */
private static final int ORDER_STATUS_CANCELLED = 2;
/** 支付状态:1 = 支付成功(见 {@code hjc_order.pay_status} 的列注释)。 */
private static final int PAY_STATUS_PAID = 1;
/** 取消请求的判定结论。 */
public enum Decision {
/** 已经是已取消:幂等出口,调用方不得再做任何后续动作(尤其不要再查单、再关单) */
ALREADY_CANCELLED,
/** 已支付(本地已落库,或微信侧已确认收款):拒绝取消 */
ALREADY_PAID,
/** 放行取消 */
PROCEED
}
/**
* 判定这一次取消请求该怎么走
*
* <p>调用方应当调用它<b>两次</b>第一次传 {@code wechatStatus = null}还没查单
* 若结论不是 {@link Decision#PROCEED} 就直接返回从而避免一次无谓的微信调用
* 查单之后再调用一次用微信侧的结论定案</p>
*
* @param payStatus 本地支付状态可为 null
* @param orderStatus 本地订单状态可为 null
* @param wechatStatus 微信侧状态名{@code PaymentStatus} name**没查到结论时传 null**
* @return 判定结论
*/
public static Decision decide(Integer payStatus, Integer orderStatus, String wechatStatus) {
// 已取消优先于一切重复取消必须走幂等出口不能因为"本地还没支付"就重新走一遍查单/关单
if (isCancelled(orderStatus)) {
return Decision.ALREADY_CANCELLED;
}
if (isLocallyPaid(payStatus)) {
return Decision.ALREADY_PAID;
}
if (WECHAT_STATUS_SUCCESS.equals(wechatStatus)) {
return Decision.ALREADY_PAID;
}
return Decision.PROCEED;
}
/**
* 本次取消是否与微信核对过
*
* <p>拿到状态名才算核对过查单抛异常或微信返回体里没有状态都只能算**没核对上**
* 未核对上时仍然放行取消否则在商户配置修好之前这个功能等于不存在
* 但响应里要如实带上 {@code verified=false}让前端把不确定性告诉用户
* 而不是替微信下结论 ADR 0009以微信侧为准同源</p>
*/
public static boolean verified(String wechatStatus) {
return wechatStatus != null && !wechatStatus.trim().isEmpty();
}
/** 判定调用方是否需要顺手把本地订单补成已支付(微信已收款、但本地还没落库)。 */
public static boolean needsSelfHeal(Integer payStatus, String wechatStatus) {
return !isLocallyPaid(payStatus) && WECHAT_STATUS_SUCCESS.equals(wechatStatus);
}
public static boolean isCancelled(Integer orderStatus) {
return Objects.equals(orderStatus, ORDER_STATUS_CANCELLED);
}
public static boolean isLocallyPaid(Integer payStatus) {
return Objects.equals(payStatus, PAY_STATUS_PAID);
}
}
@@ -0,0 +1,171 @@
package com.gxwebsoft.hjc.util;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.gxwebsoft.common.core.config.ConfigProperties;
import lombok.extern.slf4j.Slf4j;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.apache.tika.Tika;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
/**
* 证件图片上传委托统一的 OSS 上传服务
*
* <p><b>为什么是委托而不是自己 putObject</b>平台里已有一个可用的 OSS 上传服务
* `{@code config.server-url}/oss/upload` {@code server.websoft.top/api}
* {@code RequestUtil} 调的是同一个后端后台hjc-vue {@code UploadImage}正是走它上传图片
* 它会按 {@code tenantId} 取该租户的云存储配置把文件写进 OSS 并返回可访问地址
* 此前 hjc 的证件上传只写本地磁盘再把地址拼成 OSS 域名**文件从未进过 OSS**
* 于是返回的地址打不开渲染 404OCR 取图也 404这里按同一姿势委托上传避免在 mp-java
* 另起一套 OSS 凭据与代码{@code config.*} 里那套 AK 是给别的用途的不该在这里重复消费</p>
*
* <p><b>不转发调用方的 Authorization</b>该服务实测匿名即可用只认 {@code tenantId}
* hjc 前端的 JWT 与核心后端不是同一套签名转发过去只会被判 401故只带 {@code tenantId}</p>
*
* <p><b>multipart part 类型要正确</b>该服务把上传 part Content-Type 直接写成 OSS 对象元数据
* 实测part image/jpegOSS 返回的 Content-Type 就是 image/jpeg若传成
* application/octet-stream浏览器会变成下载 {@code HjcOcrImageFetcher} {@code image/*} 校验会把
* 自家上传的证件图拒掉因此这里显式解析/嗅探图片类型</p>
*/
@Slf4j
@Component
public class HjcOssUploadUtil {
/** 统一 OSS 上传服务路径(相对 {@code config.server-url} */
static final String OSS_UPLOAD_PATH = "/oss/upload";
/** 连接超时 */
static final int CONNECT_TIMEOUT_MS = 3000;
/** 读写超时:证件图最大 5M,跨服务上传给足时间 */
static final int READ_TIMEOUT_MS = 20000;
private static final Tika TIKA = new Tika();
@Resource
private ConfigProperties configProperties;
private final OkHttpClient http = new OkHttpClient.Builder()
.connectTimeout(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(READ_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.writeTimeout(READ_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build();
/**
* 上传文件到 OSS返回可访问的地址
*
* @param file 上传的文件证件图片
* @param tenantId 租户 IDOSS 上传服务据此取该租户的云存储配置
* @return 图片地址原图地址不带缩略参数
* @throws IOException 上传失败服务不可达返回失败码未返回地址
*/
public String upload(MultipartFile file, String tenantId) throws IOException {
byte[] bytes = file.getBytes();
String filename = StrUtil.blankToDefault(file.getOriginalFilename(), "upload");
RequestBody filePart = RequestBody.create(bytes, MediaType.parse(resolveContentType(file, bytes, filename)));
MultipartBody body = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", filename, filePart)
.build();
Request.Builder builder = new Request.Builder()
.url(configProperties.getServerUrl() + OSS_UPLOAD_PATH)
.post(body);
if (StrUtil.isNotBlank(tenantId)) {
builder.header("tenantId", tenantId);
}
String responseBody = execute(builder.build());
String url = extractUrl(responseBody);
log.info("HjcUpload: 证件已上传 OSS tenantId={} name={} bytes={} url={}",
tenantId, filename, bytes.length, url);
return url;
}
/**
* 执行 HTTP 调用并返回响应体该方法是唯一的 HTTP 出口测试中可覆盖以注入打桩响应
*/
protected String execute(Request request) throws IOException {
try (Response response = http.newCall(request).execute()) {
String body = response.body() == null ? null : response.body().string();
if (!response.isSuccessful()) {
throw new IOException("OSS 上传服务返回 HTTP " + response.code() + "" + brief(body));
}
if (StrUtil.isBlank(body)) {
throw new IOException("OSS 上传服务返回空响应");
}
return body;
}
}
/**
* 从响应中取可访问地址响应形如
* {@code {"code":0,"message":"操作成功","data":{"path":"https://oss.wsdns.cn/2026…/x.jpg","url":"…?x-oss-process=…"}}}
*
* <p>优先取 {@code data.path}{@code data.url} 会带 {@code ?x-oss-process} 缩略参数
* 而证件要原图缩略会影响证件识别的清晰度</p>
*/
static String extractUrl(String responseBody) throws IOException {
JSONObject json;
try {
json = JSONObject.parseObject(responseBody);
} catch (Exception e) {
throw new IOException("OSS 上传服务响应不是合法 JSON:" + brief(responseBody));
}
if (json == null || json.getIntValue("code") != 0) {
String message = json == null ? null : json.getString("message");
String error = json == null ? null : json.getString("error");
throw new IOException("OSS 上传服务返回失败:" + message + " " + brief(error));
}
JSONObject data = json.getJSONObject("data");
String path = data == null ? null : data.getString("path");
if (isHttpUrl(path)) {
return path;
}
String url = data == null ? null : data.getString("url");
if (isHttpUrl(url)) {
// 兜底去掉 x-oss-process 之类的处理参数尽量拿原图
int queryIndex = url.indexOf('?');
return queryIndex > 0 ? url.substring(0, queryIndex) : url;
}
throw new IOException("OSS 上传服务未返回图片地址:" + brief(responseBody));
}
/**
* 解析 part Content-Type优先用客户端声明的图片类型否则用 Tika 按内容嗅探
*
* <p>不能让证件图以 octet-stream 落到 OSS 见类注释</p>
*/
static String resolveContentType(MultipartFile file, byte[] bytes, String filename) {
String declared = file.getContentType();
if (declared != null && declared.startsWith("image/")) {
return declared;
}
try {
String detected = TIKA.detect(bytes, filename);
if (StrUtil.isNotBlank(detected) && detected.startsWith("image/")) {
return detected;
}
} catch (Exception e) {
log.debug("HjcUpload: 嗅探文件类型失败 name={}", filename, e);
}
return StrUtil.isNotBlank(declared) ? declared : "application/octet-stream";
}
private static boolean isHttpUrl(String value) {
return value != null && (value.startsWith("http://") || value.startsWith("https://"));
}
private static String brief(String text) {
if (text == null) {
return null;
}
return text.length() <= 200 ? text : text.substring(0, 200) + "...";
}
}
@@ -0,0 +1,176 @@
package com.gxwebsoft.hjc.util;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.gxwebsoft.hjc.dto.HjcTenderFileVo;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 标书附件{@code hjc_bid_project.tender_file}的解析纯函数不依赖 Spring
*
* <p><b>为什么要解析</b>这个字段是从一站式推送里原样落库的字符串契约是
* 数组JSON或逗号分隔{@code HjcOneStopProjectPush#files}也就是至少三种形态
* 字符串数组对象数组带文件名逗号分隔前端三处h5 两个运行端 + PC都要渲染它
* 解析放前端必然分叉放在这里还能用单测把坏数据这一支钉住</p>
*
* <p><b>绝不抛异常</b>一个坏附件不能让整张订单列表打不开 OCR识别失败不阻断同一取舍
* 无法识别的条目就地丢弃能认多少给多少</p>
*/
public final class HjcTenderFileUtil {
private HjcTenderFileUtil() {
}
/** 取不出文件名时的兜底展示名 */
public static final String DEFAULT_NAME = "标书附件";
/** 非 JSON 形态的分隔符:英文/中文逗号、分号、换行 */
private static final String DELIMITERS = "[,;\\r\\n]";
/**
* 把库里的原始字符串解析成附件列表
*
* <p>规则</p>
* <ul>
* <li>空白 空列表</li>
* <li> {@code [} / {@code {} 开头 JSON 解析数组元素可为字符串或对象
* <b>JSON 坏了就返回空列表不回退到按逗号切分</b>形如 JSON 的坏数据切出来的
* 碎片不是地址宁可没有</li>
* <li>其余 {@link #DELIMITERS} 切分</li>
* <li>地址为空白的条目丢弃同名地址按 {@code url} 去重保持首次出现的顺序</li>
* </ul>
*
* @param raw 库里的 {@code tender_file}可为 null
* @return null 的列表可能为空
*/
public static List<HjcTenderFileVo> parse(String raw) {
List<HjcTenderFileVo> files = new ArrayList<>();
if (StrUtil.isBlank(raw)) {
return files;
}
String text = raw.trim();
Set<String> seen = new LinkedHashSet<>();
if (text.startsWith("[")) {
JSONArray array;
try {
array = JSON.parseArray(text);
} catch (Exception e) {
return files;
}
if (array == null) {
return files;
}
for (int i = 0; i < array.size(); i++) {
addFromJsonElement(array.get(i), seen, files);
}
return files;
}
if (text.startsWith("{")) {
JSONObject object;
try {
object = JSON.parseObject(text);
} catch (Exception e) {
return files;
}
if (object != null) {
addFromJsonObject(object, seen, files);
}
return files;
}
for (String piece : text.split(DELIMITERS)) {
addFile(piece, null, seen, files);
}
return files;
}
/** 数组的一个元素:字符串直接当地址;对象取 url/name;其余类型(数字/布尔/嵌套)丢弃 */
private static void addFromJsonElement(Object element, Set<String> seen, List<HjcTenderFileVo> files) {
if (element == null) {
return;
}
if (element instanceof JSONObject) {
addFromJsonObject((JSONObject) element, seen, files);
return;
}
if (element instanceof CharSequence) {
addFile(element.toString(), null, seen, files);
}
}
private static void addFromJsonObject(JSONObject object, Set<String> seen, List<HjcTenderFileVo> files) {
String url = firstNotBlank(object, "url", "fileUrl", "fileURL", "path", "downloadUrl");
String name = firstNotBlank(object, "name", "fileName", "filename", "title");
addFile(url, name, seen, files);
}
private static void addFile(String url, String name, Set<String> seen, List<HjcTenderFileVo> files) {
if (StrUtil.isBlank(url)) {
return;
}
String trimmedUrl = url.trim();
if (!seen.add(trimmedUrl)) {
return;
}
HjcTenderFileVo vo = new HjcTenderFileVo();
vo.setUrl(trimmedUrl);
vo.setName(StrUtil.isBlank(name) ? nameFromUrl(trimmedUrl) : name.trim());
files.add(vo);
}
private static String firstNotBlank(JSONObject object, String... keys) {
for (String key : keys) {
String value = object.getString(key);
if (StrUtil.isNotBlank(value)) {
return value;
}
}
return null;
}
/**
* 从地址里取一个能看的展示名去掉 query/fragment 后取最后一段取不到就用默认名
*
* <p>末段若带百分号编码中文文件名常见就试着解码解不开不是合法编码时原样返回
* 不因为一个名字让整条记录丢掉</p>
*/
private static String nameFromUrl(String url) {
String path = url;
int cut = indexOfAny(path, '?', '#');
if (cut >= 0) {
path = path.substring(0, cut);
}
int slash = path.lastIndexOf('/');
String name = slash >= 0 ? path.substring(slash + 1) : path;
name = name.trim();
if (name.isEmpty()) {
return DEFAULT_NAME;
}
if (name.indexOf('%') >= 0) {
try {
name = URLDecoder.decode(name, "UTF-8").trim();
} catch (Exception ignored) {
// 不是合法的百分号编码保留原样好过丢一条附件
}
}
return name.isEmpty() ? DEFAULT_NAME : name;
}
private static int indexOfAny(String text, char a, char b) {
int i = text.indexOf(a);
int j = text.indexOf(b);
if (i < 0) {
return j;
}
if (j < 0) {
return i;
}
return Math.min(i, j);
}
}
@@ -0,0 +1,76 @@
package com.gxwebsoft.hjc.util;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 汇吉采微信接入自检的判定逻辑
*
* <p><b>为什么单独抽出来</b>这段逻辑的价值在于配置缺哪一项的判定与文案而它天然是纯函数
* 抽成不依赖 Spring 的静态方法后可以直接单测微信接入的排查成本很高四套配置三套 Redis
* 跨库 sys_payment以及支付 appId 必须与小程序 appId 同值这条隐性约束
* 判定错了会把排查方向带偏所以它值得有测试</p>
*/
public final class HjcWechatReadinessUtil {
private HjcWechatReadinessUtil() {
}
/** 追加一条自检项;返回该项是否通过,便于调用方累积总结果 */
public static boolean addCheck(List<Map<String, Object>> checks, String item, boolean pass,
String why, String detail) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("item", item);
row.put("pass", pass);
row.put("why", why);
row.put("detail", detail);
checks.add(row);
return pass;
}
public static boolean notBlank(String s) {
return s != null && !s.trim().isEmpty();
}
/**
* 打码只留前 6 4
*
* <p>自检结果会回给前端即便调用者是管理员也没有必要把完整 appId/商户号回显
* appSecret 这类密钥<b>永不</b>进入自检输出</p>
*/
public static String mask(String v) {
if (v == null) {
return null;
}
String s = v.trim();
return s.length() <= 10 ? s : s.substring(0, 6) + "" + s.substring(s.length() - 4);
}
/**
* 支付 appId 是否与小程序 appId 同值
*
* <p>这是本方案里最容易配错且报错最难懂的一条微信要求 JSAPI openid
* 商户号绑定的 appid同源而本方案的 openid 来自小程序两者不同值时
* 下单会以 {@code APPID_MCHID_NOT_MATCH} openid appid 不匹配失败
* 表面看却像商户号没绑定</p>
*
* <p>任一侧为空时返回 {@code false}此时真正的问题在别处配置缺失
* 由对应的单项检查去报这里不应给出一致的假结论</p>
*/
public static boolean sameAppId(String payAppId, String mpAppId) {
return notBlank(payAppId) && notBlank(mpAppId)
&& payAppId.trim().equalsIgnoreCase(mpAppId.trim());
}
/** 构造自检汇总:ready 为各单项的与,并给出统一说明 */
public static Map<String, Object> summary(Integer tenantId, boolean ready,
List<Map<String, Object>> checks) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("tenantId", tenantId);
data.put("ready", ready);
data.put("checks", checks);
data.put("note", "probe=true 时会实调微信验证 appSecret(需外网),默认只做本地检查");
return data;
}
}

Some files were not shown because too many files have changed in this diff Show More