Compare commits
25 Commits
79666898c5
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bc66b5808 | |||
| 6d8185cc70 | |||
| 13cb08621f | |||
| fdff54a6cf | |||
| 6e475bea03 | |||
| a3c54249dc | |||
| 2cd60270b9 | |||
| b2b1a1bdf9 | |||
| da1f9a64d4 | |||
| 27dbea9b6f | |||
| ce1d0a2a8a | |||
| 5d88684820 | |||
| 1e2ec82c18 | |||
| 08138f1f04 | |||
| 9d188d304b | |||
| 1aa8b8d4e4 | |||
| 32a41bde6d | |||
| 9b050c9dc5 | |||
| 32a3d1918c | |||
| ab2f615067 | |||
| 10099a1d5a | |||
| 4cbd925544 | |||
| b2f310b81d | |||
| 1bcb805d65 | |||
| 770aeff4d7 |
+43
@@ -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_: 同步, 对接
|
||||
@@ -0,0 +1,3 @@
|
||||
# 一站式推送鉴权:appKey + timestamp + sign,password 不进报文
|
||||
|
||||
汇吉采 → 一站式平台的订单推送采用 `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 关系已不存在,拆表只会增加复杂度而无对应业务收益;等未来确实出现「一项目多标书」再演进(届时把标书款从主表抽出即可,主表结构无须预埋)。
|
||||
@@ -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」的弹性分流,非公众号环境(如普通浏览器/开发联调)仍可扫码支付,便于无真实公众号配置时验证。
|
||||
@@ -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、password,appKey+年月日时分、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` 请求体字段表(本平台另一份文档,见需求/接口清单)的具体入参名与必填项,需对方给出或双方对齐,以免字段名不一致导致落盘失败。
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
@@ -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+sign(sign=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_token,raw={}", 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) {
|
||||
// 后台**保留** tenderFile:hjc-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)与 PC(Native)
|
||||
// 各发起一次时必现 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_time(spec 决策 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 换 openid(POST /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_token,JS-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 +
|
||||
"×tamp=" + 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 <> ''
|
||||
AND g.deleted = 0
|
||||
AND g.status = 1
|
||||
AND (g.start_time IS NULL OR g.start_time <= NOW())
|
||||
AND (g.end_time IS NULL OR g.end_time >= 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 >= #{param.createTimeStart}
|
||||
</if>
|
||||
<if test="param.createTimeEnd != null">
|
||||
AND a.create_time <= #{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 >= #{param.createTimeStart}
|
||||
</if>
|
||||
<if test="param.createTimeEnd != null">
|
||||
AND a.create_time <= #{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 >= #{param.createTimeStart}
|
||||
</if>
|
||||
<if test="param.createTimeEnd != null">
|
||||
AND a.create_time <= #{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 >= #{param.createTimeStart}
|
||||
</if>
|
||||
<if test="param.createTimeEnd != null">
|
||||
AND a.create_time <= #{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 >= #{param.createTimeStart}
|
||||
</if>
|
||||
<if test="param.createTimeEnd != null">
|
||||
AND a.create_time <= #{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 >= #{param.createTimeStart}
|
||||
</if>
|
||||
<if test="param.createTimeEnd != null">
|
||||
AND a.create_time <= #{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_token:POST,参数 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/111:token 无效或过期,需丢弃缓存 */
|
||||
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;
|
||||
}
|
||||
// 不写 XML:wrapper 查询由租户拦截器自动附加 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,所以这是一条真 DELETE(ADR 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>已下架 > 已结束 > 在售</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**,
|
||||
* 于是返回的地址打不开(渲染 404、OCR 取图也 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/jpeg,OSS 返回的 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 租户 ID:OSS 上传服务据此取该租户的云存储配置
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,15 @@ import com.gxwebsoft.payment.exception.PaymentException;
|
||||
import com.gxwebsoft.payment.enums.PaymentType;
|
||||
import com.wechat.pay.java.core.Config;
|
||||
import com.wechat.pay.java.core.RSAAutoCertificateConfig;
|
||||
import com.wechat.pay.java.core.RSAPublicKeyConfig;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@@ -101,8 +104,11 @@ public class WxPayConfigService {
|
||||
// 获取证书文件路径
|
||||
String certificatePath = getCertificatePath(tenantId, payment);
|
||||
|
||||
// 获取微信支付公钥文件路径(未配置公钥ID时为null,表示使用平台证书自动下载模式)
|
||||
String publicKeyPath = resolvePublicKeyPath(tenantId, payment);
|
||||
|
||||
// 创建微信支付配置对象
|
||||
return createWxPayConfig(payment, certificatePath);
|
||||
return createWxPayConfig(payment, certificatePath, publicKeyPath);
|
||||
|
||||
} catch (Exception e) {
|
||||
if (e instanceof PaymentException) {
|
||||
@@ -232,17 +238,96 @@ public class WxPayConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析微信支付公钥文件路径
|
||||
*
|
||||
* <p>商户平台已「申请使用微信支付公钥」的商户,调用 /v3/certificates 会返回
|
||||
* RESOURCE_NOT_EXISTS(404),必须改用公钥模式(RSAPublicKeyConfig)。
|
||||
* 是否启用公钥模式以数据库中的公钥ID(pubKeyId)为准:
|
||||
* 配置了公钥ID却没有可用的公钥文件时直接报错,避免退回平台证书自动下载后
|
||||
* 再次抛出难以定位的 404。</p>
|
||||
*
|
||||
* @param tenantId 租户ID
|
||||
* @param payment 支付配置
|
||||
* @return 公钥文件路径;未配置公钥ID时返回 null
|
||||
* @throws PaymentException 启用了公钥模式但公钥文件不可用时抛出
|
||||
*/
|
||||
private String resolvePublicKeyPath(Integer tenantId, Payment payment) throws PaymentException {
|
||||
if (payment == null || !StringUtils.hasText(payment.getPubKeyId())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ("dev".equals(activeProfile)) {
|
||||
return resolveDevPublicKeyPath(tenantId);
|
||||
}
|
||||
return resolveProdPublicKeyPath(payment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取开发环境微信支付公钥路径
|
||||
*/
|
||||
private String resolveDevPublicKeyPath(Integer tenantId) throws PaymentException {
|
||||
String certDir = certificateProperties.getDevCertPath() + "/"
|
||||
+ certificateProperties.getWechatPay().getCertDir() + "/" + tenantId + "/";
|
||||
|
||||
// 优先使用配置的公钥文件名,并兼容证书目录下实际的 pub_key.pem
|
||||
String[] candidates = {
|
||||
certificateProperties.getWechatPay().getDev().getWechatpayCertFile(),
|
||||
"pub_key.pem"
|
||||
};
|
||||
|
||||
for (String fileName : candidates) {
|
||||
if (!StringUtils.hasText(fileName)) {
|
||||
continue;
|
||||
}
|
||||
ClassPathResource resource = new ClassPathResource(certDir + fileName);
|
||||
if (resource.exists()) {
|
||||
try {
|
||||
String absolutePath = resource.getFile().getAbsolutePath();
|
||||
log.debug("开发环境微信支付公钥路径: {}", absolutePath);
|
||||
return absolutePath;
|
||||
} catch (IOException e) {
|
||||
throw PaymentException.systemError("获取开发环境微信支付公钥路径失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw PaymentException.systemError(
|
||||
"已配置微信支付公钥ID但缺少公钥文件,租户ID: " + tenantId
|
||||
+ ",预期位置: classpath:" + certDir + "pub_key.pem"
|
||||
+ "(请从商户平台-API安全下载微信支付公钥后放到该目录)", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取生产环境微信支付公钥路径
|
||||
*/
|
||||
private String resolveProdPublicKeyPath(Payment payment) throws PaymentException {
|
||||
if (!StringUtils.hasText(payment.getPubKey())) {
|
||||
throw PaymentException.systemError(
|
||||
"已配置微信支付公钥ID但公钥文件(pubKey)未配置,商户号: " + payment.getMchId(), null);
|
||||
}
|
||||
|
||||
String publicKeyPath = certificateService.getWechatPayCertPath(payment.getPubKey());
|
||||
if (publicKeyPath == null || !new File(publicKeyPath).exists()) {
|
||||
throw PaymentException.systemError(
|
||||
"微信支付公钥文件不存在: " + publicKeyPath + ",商户号: " + payment.getMchId(), null);
|
||||
}
|
||||
|
||||
log.debug("生产环境微信支付公钥路径: {}", publicKeyPath);
|
||||
return publicKeyPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建微信支付配置对象
|
||||
*/
|
||||
private Config createWxPayConfig(Payment payment, String certificatePath) throws PaymentException {
|
||||
private Config createWxPayConfig(Payment payment, String certificatePath, String publicKeyPath) throws PaymentException {
|
||||
try {
|
||||
if ("dev".equals(activeProfile) && payment == null) {
|
||||
// 开发环境测试配置
|
||||
return createDevTestConfig(certificatePath);
|
||||
} else if (payment != null) {
|
||||
// 正常配置
|
||||
return createNormalConfig(payment, certificatePath);
|
||||
return createNormalConfig(payment, certificatePath, publicKeyPath);
|
||||
} else {
|
||||
throw PaymentException.systemError("无法创建微信支付配置:配置信息不完整", null);
|
||||
}
|
||||
@@ -305,21 +390,57 @@ public class WxPayConfigService {
|
||||
/**
|
||||
* 创建正常配置
|
||||
*/
|
||||
private Config createNormalConfig(Payment payment, String certificatePath) throws PaymentException {
|
||||
private Config createNormalConfig(Payment payment, String certificatePath, String publicKeyPath) throws PaymentException {
|
||||
// 验证配置完整性
|
||||
validatePaymentConfig(payment);
|
||||
|
||||
log.info("使用数据库支付配置");
|
||||
log.debug("商户号: {}", payment.getMchId());
|
||||
|
||||
return buildRsaConfig(payment, certificatePath, publicKeyPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建RSA配置
|
||||
*
|
||||
* <p>配置了微信支付公钥ID与公钥文件时使用公钥模式(RSAPublicKeyConfig),
|
||||
* 否则使用平台证书自动下载模式(RSAAutoCertificateConfig)。
|
||||
* 公钥模式下不会请求 /v3/certificates,因此不受「无可用的平台证书」404影响。</p>
|
||||
*
|
||||
* @param payment 支付配置
|
||||
* @param privateKeyPath 商户私钥文件路径
|
||||
* @param publicKeyPath 微信支付公钥文件路径,可为 null
|
||||
* @return 微信支付配置
|
||||
*/
|
||||
static Config buildRsaConfig(Payment payment, String privateKeyPath, String publicKeyPath) {
|
||||
if (usePublicKeyMode(payment.getPubKeyId(), publicKeyPath)) {
|
||||
log.info("使用微信支付公钥模式,商户号: {}, 公钥ID: {}", payment.getMchId(), payment.getPubKeyId());
|
||||
return new RSAPublicKeyConfig.Builder()
|
||||
.merchantId(payment.getMchId())
|
||||
.privateKeyFromPath(privateKeyPath)
|
||||
.merchantSerialNumber(payment.getMerchantSerialNumber())
|
||||
.publicKeyFromPath(publicKeyPath)
|
||||
.publicKeyId(payment.getPubKeyId())
|
||||
.apiV3Key(payment.getApiKey())
|
||||
.build();
|
||||
}
|
||||
|
||||
log.info("使用微信支付平台证书自动下载模式,商户号: {}", payment.getMchId());
|
||||
return new RSAAutoCertificateConfig.Builder()
|
||||
.merchantId(payment.getMchId())
|
||||
.privateKeyFromPath(certificatePath)
|
||||
.privateKeyFromPath(privateKeyPath)
|
||||
.merchantSerialNumber(payment.getMerchantSerialNumber())
|
||||
.apiV3Key(payment.getApiKey())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否使用微信支付公钥模式:配置了公钥ID且公钥文件可用
|
||||
*/
|
||||
static boolean usePublicKeyMode(String publicKeyId, String publicKeyPath) {
|
||||
return StringUtils.hasText(publicKeyId) && StringUtils.hasText(publicKeyPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证支付配置完整性
|
||||
*/
|
||||
@@ -356,10 +477,14 @@ public class WxPayConfigService {
|
||||
/**
|
||||
* 清除指定租户的配置缓存
|
||||
*
|
||||
* <p>同时清除内存中的微信支付配置与Redis中的支付配置(Payment:1:{tenantId}),
|
||||
* 否则数据库中的支付配置(如公钥ID)变更后最长24小时才能生效。</p>
|
||||
*
|
||||
* @param tenantId 租户ID
|
||||
*/
|
||||
public void clearConfigCache(Integer tenantId) {
|
||||
WxNativeUtil.addConfig(tenantId, null);
|
||||
redisUtil.delete("Payment:1:" + tenantId);
|
||||
log.info("清除微信支付配置缓存,租户ID: {}", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user