初始化天卡开放平台后端项目

包含完整功能模块:
- 设备激活(直销激活码模式 + 代销免激活码模式)
- 微信支付 + 天卡订单
- 天卡池管理 + 异步橘视厅SDK激活
- 第三方合作伙伴接口(代销模式)
  - 签名鉴权(appKey/appSecret + nonce防重放)
  - 天卡消耗(事务 + 幂等 + 并发控制)
  - 媒资接口(三级授权过滤)
  - 审计日志
- 定价策略(3层:门店 > 渠道 > 平台)
- 分账(微信分账 + 分润规则)
- Docker部署(含Redis容器)
This commit is contained in:
zhaogd
2026-07-26 12:34:13 +08:00
commit 30198c7191
349 changed files with 28837 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Build output
target/
*.class
# IDE
.idea/
*.iml
.vscode/
*.swp
# Logs
logs/
*.log
# Redis dump
dump.rdb
# Python test scripts (not part of project)
*.py
# TRAE generated
.trae-html-share-packages/
# OS files
.DS_Store
Thumbs.db
# Sensitive config (keep templates)
# application-prod.yml contains production credentials
src/main/resources/application-prod.yml

View File

@@ -0,0 +1,7 @@
---
alwaysApply: true
scene: git_message
---
在此处编写规则,自定义 AI 生成提交信息的风格。
使用中文编写提交信息。

388
API_DOC.md Normal file
View File

@@ -0,0 +1,388 @@
# 天卡开放平台 · 接口对接文档
> 项目:as-daycard-openapi | 端口:8084 | 更新:2026-07-22
---
## 一、统一规范
### 返回格式(所有接口统一)
```json
{"code": 0, "msg": "success", "data": {...}}
```
- `code=0` 成功,非0失败
- `data` 业务数据,统一放在 data 字段内
- 错误时:`{"code": 错误码, "msg": "错误信息", "data": {}}`
### 接口分类
| 分类 | 路径前缀 | 鉴权 | 请求头 | 调用方 |
|------|---------|------|--------|--------|
| 管理后台 | `/manage/**`、`/sys/login` | JWT | `token` | 浏览器前端 |
| 第三方开放 | `/openapi/partner/**` | appKey签名 | `appKey`/`timestamp`/`nonce`/`sign` | 第三方EPG |
| APK设备 | `/api/jst/**` | 设备sign | `sign` | 我们APK |
| 支付回调 | `/openapi/payment/**`、`/openapi/settle/**` | 无 | - | 微信服务器 |
---
## 二、管理后台接口(JWT鉴权)
**鉴权**:请求头 `token: <登录返回的token>`
**登录**:`POST /sys/login`(免鉴权)返回 token
**返回**:`{code:0, msg:"success", data:{...}}`
### 2.1 登录鉴权
| 路径 | 方法 | 说明 |
|------|------|------|
| `POST /sys/login` | POST | 登录(username/password/uuid/captcha),返回token+refreshToken |
| `POST /sys/refresh` | POST | 刷新token(refreshToken) |
| `POST /sys/logout` | POST | 退出登录 |
| `GET /captcha.jpg` | GET | 验证码图片(参数uuid) |
### 2.2 用户管理 `/manage/user`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/user/list` | GET | 用户列表(分页) |
| `/manage/user/info` | GET | 当前登录用户信息 |
| `/manage/user/info/{userId}` | GET | 用户详情 |
| `/manage/user/save` | POST | 保存用户 |
| `/manage/user/update` | POST | 修改用户 |
| `/manage/user/delete` | POST | 删除用户 |
| `/manage/user/password` | POST | 修改密码 |
### 2.3 角色管理 `/manage/role`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/role/list` | GET | 角色列表 |
| `/manage/role/select` | GET | 角色下拉 |
| `/manage/role/info/{roleId}` | GET | 角色详情(含menuIdList) |
| `/manage/role/save` | POST | 保存角色 |
| `/manage/role/update` | POST | 修改角色 |
| `/manage/role/delete` | POST | 删除角色 |
### 2.4 菜单管理 `/manage/menu`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/menu/nav` | GET | 当前用户菜单+权限 |
| `/manage/menu/list` | GET | 菜单列表 |
| `/manage/menu/select` | GET | 菜单下拉 |
| `/manage/menu/info/{menuId}` | GET | 菜单详情 |
| `/manage/menu/save` | POST | 保存菜单 |
| `/manage/menu/update` | POST | 修改菜单 |
| `/manage/menu/delete/{menuId}` | POST | 删除菜单 |
### 2.5 组织管理 `/manage/org`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/org/list` | GET | 组织列表(树) |
| `/manage/org/tree` | GET | 组织树(下拉) |
| `/manage/org/all-tree` | GET | 组织树(含门店) |
| `/manage/org/list-data` | GET | 组织列表(扁平) |
| `/manage/org/channels` | GET | 渠道列表 |
| `/manage/org/channel/{channelId}/agent-tree` | GET | 渠道下代理树 |
| `/manage/org/{orgId}/card-stats` | GET | 组织卡统计 |
| `/manage/org/card-type-detail-stats` | GET | 卡类型明细统计 |
| `/manage/org/card-overview` | GET | 组织卡概览 |
| `/manage/org/{orgId}/same-channel-orgs` | GET | 同渠道组织树 |
| `/manage/org/info/{id}` | GET | 组织详情 |
| `/manage/org/save` | POST | 保存组织 |
| `/manage/org/update` | POST | 修改组织 |
| `/manage/org/delete` | POST | 删除组织 |
### 2.6 门店管理 `/manage/store`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/store/list` | GET | 门店列表 |
| `/manage/store/list-by-agent/{agentOrgId}` | GET | 按代理商列门店 |
| `/manage/store/search` | GET | 门店搜索(分页) |
| `/manage/store/{storeOrgId}/cards` | GET | 门店绑定卡列表 |
### 2.7 合同权益 `/manage/contract`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/contract/by-store` | POST | 按门店查权益激活码 |
### 2.8 天卡激活码 `/manage/daycard/activation-code`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/daycard/activation-code/list/{storeOrgId}` | GET | 按门店查激活码 |
| `/manage/daycard/activation-code/generate` | POST | 生成激活码 |
| `/manage/daycard/activation-code/{id}/regenerate` | POST | 重新生成code |
| `/manage/daycard/activation-code/{id}/status` | PUT | 启用/禁用 |
| `/manage/daycard/activation-code/{id}` | DELETE | 删除激活码 |
### 2.9 天卡管理 `/manage/daycard`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/daycard/pool/list` | GET | 天卡池列表 |
| `/manage/daycard/pool/stats` | GET | 库存统计 |
| `/manage/daycard/pool/import` | POST | 导入天卡卡密 |
| `/manage/daycard/order/list` | GET | 天卡订单列表 |
| `/manage/daycard/list` | GET | 天卡订单列表 |
| `/manage/daycard/info/{orderNo}` | GET | 订单详情 |
| `/manage/daycard/benefits/{deviceId}` | GET | 设备天卡权益 |
| `/manage/daycard/device/unbind` | POST | 解绑设备天卡 |
| `/manage/daycard/device/unbind-org` | POST | 设备组织解绑 |
### 2.10 天卡定价 `/manage/price`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/price/list` | GET | 定价策略列表 |
| `/manage/price/info/{id}` | GET | 定价详情 |
| `/manage/price/effective` | GET | 生效中定价(门店>渠道>平台) |
| `/manage/price/save` | POST | 新增定价 |
| `/manage/price/update` | POST | 修改定价 |
| `/manage/price/delete` | POST | 删除定价 |
### 2.11 天卡退款 `/manage/refund`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/refund/list` | GET | 退款记录列表 |
| `/manage/refund/info/{refundNo}` | GET | 退款详情 |
| `/manage/refund/apply` | POST | 发起退款(orderNo+reason) |
### 2.12 分润规则 `/manage/profit-rule`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/profit-rule/list` | GET | 分润规则列表 |
| `/manage/profit-rule/info/{id}` | GET | 规则详情(含items) |
| `/manage/profit-rule/save` | POST | 新增分润规则 |
| `/manage/profit-rule/update` | POST | 修改分润规则 |
| `/manage/profit-rule/delete` | POST | 删除分润规则 |
| `/manage/profit-rule/preview` | POST | 分润试算(超30%告警) |
### 2.13 分账查询 `/manage/profit-sharing`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/profit-sharing/list` | GET | 分账单列表(含details) |
| `/manage/profit-sharing/info/{id}` | GET | 分账单详情 |
| `/manage/profit-sharing/stats` | GET | 分账统计(按日期/组织) |
### 2.14 分账接收方 `/manage/profit-receiver`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/profit-receiver/list` | GET | 接收方列表 |
| `/manage/profit-receiver/info/{id}` | GET | 接收方详情 |
| `/manage/profit-receiver/save` | POST | 新增接收方 |
| `/manage/profit-receiver/delete` | POST | 删除接收方 |
| `/manage/profit-receiver/sync/{id}` | POST | 同步到微信 |
### 2.15 设备管理 `/manage/device`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/device/list` | GET | 设备列表 |
| `/manage/device/info/{id}` | GET | 设备详情 |
| `/manage/device/save` | POST | 新增设备 |
| `/manage/device/update` | POST | 修改设备 |
| `/manage/device/delete` | POST | 删除设备 |
| `/manage/device/bindStore` | POST | 绑定门店 |
### 2.16 支付配置 `/manage/payconfig`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/payconfig/list` | GET | 支付配置列表 |
| `/manage/payconfig/info/{id}` | GET | 配置详情 |
| `/manage/payconfig/save` | POST | 新增配置 |
| `/manage/payconfig/update` | POST | 修改配置 |
| `/manage/payconfig/delete` | POST | 删除配置 |
### 2.17 统计看板 `/manage/stats`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/stats/overview` | GET | 总览看板 |
| `/manage/stats/orders` | GET | 订单趋势(startDate/endDate) |
| `/manage/stats/pool` | GET | 库存统计 |
| `/manage/stats/channels` | GET | 渠道统计 |
### 2.18 橘视厅媒资 `/manage/jetsen-media`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/jetsen-media/nav` | GET | 首页导航 |
| `/manage/jetsen-media/tags` | GET | 栏目标签(parentid) |
| `/manage/jetsen-media/catalog-tree` | GET | 栏目树(一级+二级) |
| `/manage/jetsen-media/list` | GET | 内容列表(catalogid必填) |
| `/manage/jetsen-media/search` | GET | 搜索(keyword) |
| `/manage/jetsen-media/export` | GET | 导出Excel |
### 2.19 合作伙伴管理 `/manage/partner`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/partner/list` | GET | 合作伙伴列表 |
| `/manage/partner/info/{id}` | GET | 合作伙伴详情 |
| `/manage/partner/save` | POST | 新增(自动生成appKey/appSecret) |
| `/manage/partner/update` | POST | 修改 |
| `/manage/partner/delete` | POST | 删除 |
### 2.20 内容包管理 `/manage/content-package`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/content-package/list` | GET | 内容包列表 |
| `/manage/content-package/info/{id}` | GET | 内容包详情 |
| `/manage/content-package/info-by-partner/{partnerId}` | GET | 按partner查内容包 |
| `/manage/content-package/save` | POST | 新增内容包 |
| `/manage/content-package/update` | POST | 修改内容包 |
| `/manage/content-package/delete` | POST | 删除内容包 |
### 2.21 天卡采购 `/manage/partner-card`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/manage/partner-card/purchase` | POST | 采购天卡(partnerId+quantity) |
| `/manage/partner-card/stock` | GET | partner库存查询 |
| `/manage/partner-card/list` | GET | 池子记录分页 |
---
## 三、第三方开放接口(appKey签名鉴权)
### 鉴权说明
**签名算法**:`sign = MD5(appKey + timestamp + nonce + appSecret).toUpperCase()`
**请求头**:
| 请求头 | 说明 |
|--------|------|
| appKey | 平台分配的应用Key |
| timestamp | 毫秒时间戳(5分钟有效) |
| nonce | 随机串(5分钟防重放) |
| sign | 签名(大写MD5) |
**返回**:`{code:0, msg:"", data:{...}}`
**鉴权失败**:HTTP 401 `{code:401, msg:"错误描述"}`
### 3.1 媒资接口 `/openapi/partner/media`
**三级授权**:CONTENT(单部内容) > CATALOG(栏目) > FULL(全量)
| 路径 | 方法 | 参数 | 说明 |
|------|------|------|------|
| `/openapi/partner/media/package` | GET | 无 | 查看授权范围 |
| `/openapi/partner/media/catalog-tree` | GET | 无 | 栏目树(按授权过滤) |
| `/openapi/partner/media/list` | GET | catalogid,pageindex,pagesize | 内容列表(按授权过滤) |
| `/openapi/partner/media/search` | GET | keyword,pageindex,pagesize | 搜索(按授权过滤) |
### 3.2 天卡代销 `/openapi/partner/card`
| 路径 | 方法 | 参数 | 说明 |
|------|------|------|------|
| `/openapi/partner/card/consume` | POST | body:{mac,deviceUid,deviceSn,channelOrgId} | 消耗天卡:分配1张绑定设备+异步激活 |
| `/openapi/partner/card/stock` | GET | 无 | 我的可用天卡库存 |
**consume返回**:
```json
{"code":0,"msg":"","data":{"deviceId":123,"thirdCardNo":"xxx","cardCode":"xxx","expireAt":"...","status":"ACTIVE"}}
```
---
## 四、APK设备接口(设备sign鉴权)
### 鉴权说明
- **sign获取**:`POST /api/jst/init` 返回sign(在data内)
- **sign校验**:后续请求头携带 `sign`
- **免sign接口**:`POST /api/jst/init`
- **返回**:`{code:0, msg:"success", data:{...}}`
### 4.1 设备接口 `/api/jst`
| 路径 | 方法 | 鉴权 | 说明 |
|------|------|------|------|
| `/api/jst/init` | POST | 无 | 设备初始化,返回sign。**代销模式**:不传activationCode时按设备标识查找已注册设备 |
| `/api/jst/checkBenefits` | POST | sign | 查询天卡权益(ACTIVE返回thirdCardNo/UNBOUND) |
| `/api/jst/price` | POST | sign | 查询天卡价格(3层匹配) |
| `/api/jst/daycard/create` | POST | sign | 创建天卡订单(返回codeUrl二维码) |
| `/api/jst/daycard/status/{orderNo}` | GET | sign | 查询支付状态 |
| `/api/jst/heartBeat` | POST | sign | 心跳续期sign |
| `/api/jst/refreshSign` | POST | sign | 刷新sign(旧sign失效) |
**init请求体**:
```json
{"activationCode":"激活码(代销模式不传)","mac":"设备MAC","deviceUid":"设备标识","deviceSn":"序列号"}
```
**init返回**:
```json
{"code":0,"msg":"success","data":{"device":{"deviceId":123,"roomNo":"001"},"sign":"xxx"}}
```
### 4.2 UI数据接口 `/api/jst`
| 路径 | 方法 | 说明 |
|------|------|------|
| `/api/jst/topButtons` | POST | 顶栏按钮(logo/搜索/帮助) |
| `/api/jst/filter/tags` | POST | 筛选分类(栏目/类型/地区/年代) |
| `/api/jst/filter/content` | POST | 筛选内容列表 |
| `/api/jst/config` | GET | 独立配置(logoUrl+loginBg) |
### 4.3 APK调用流程
```
正常模式:init(activationCode) -> checkBenefits -> 无权益则price+create+支付+status -> checkBenefits拿thirdCardNo播放
代销模式:第三方consume消耗天卡 -> APK init(免activationCode,传设备标识) -> checkBenefits拿thirdCardNo播放
```
---
## 五、支付回调接口(微信服务器调用)
| 路径 | 方法 | 说明 |
|------|------|------|
| `/openapi/payment/wechat/notify` | POST | 微信支付回调(验签+发卡) |
| `/openapi/payment/notify` | POST | 微信支付回调(旧路径兼容) |
| `/openapi/payment/refund/notify` | POST | 微信退款回调 |
| `/openapi/payment/wechat/profit-sharing/notify` | POST | 微信分账回调(明细级) |
| `/openapi/settle/notify` | POST | 微信分账回调(结算级) |
---
## 六、路径变更对照表(前端适配用)
| 旧路径 | 新路径 |
|--------|--------|
| `/sys/user/**` | `/manage/user/**` |
| `/sys/role/**` | `/manage/role/**` |
| `/sys/menu/**` | `/manage/menu/**` |
| `/sys/org/**` | `/manage/org/**` |
| `/sys/store/**` | `/manage/store/**` |
| `/sys/contract/**` | `/manage/contract/**` |
| `/sys/jetsen-media/**` | `/manage/jetsen-media/**` |
| `/openapi/partner/list` | `/manage/partner/list` |
| `/openapi/partner/info/**` | `/manage/partner/info/**` |
| `/openapi/partner/save` | `/manage/partner/save` |
| `/openapi/partner/update` | `/manage/partner/update` |
| `/openapi/partner/delete` | `/manage/partner/delete` |
| `/openapi/daycard/pool/**` | `/manage/daycard/pool/**`(已有,DayCardPoolController已删) |
| `/sys/login` | `/sys/login`(不变) |
| `/sys/refresh` | `/sys/refresh`(不变) |
| `/sys/logout` | `/sys/logout`(不变) |
## 七、返回格式变更对照
| 接口类型 | 旧格式 | 新格式 |
|---------|--------|--------|
| APK设备 | `{code:200, msg:"success", data:{...}}` | `{code:0, msg:"success", data:{...}}` |
| APK UI | `{retCode:"00000000", data:{...}}` | `{code:0, msg:"success", data:{...}}` |
| APK init | `{code:200,...,data:{device:{}},sign:"xxx"}` | `{code:0,...,data:{device:{},sign:"xxx"}}` |
| 管理后台 | `{code:0, msg:"success", page:{...}}` | `{code:0, msg:"success", data:{page:{...}}}` |

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests -B
FROM eclipse-temurin:17-jre-alpine
LABEL maintainer="as-team"
WORKDIR /app
COPY --from=builder /build/target/daycard-openapi.jar /app/daycard-openapi.jar
EXPOSE 8084
ENTRYPOINT ["java", "-jar", "/app/daycard-openapi.jar"]

95
HANDOVER.md Normal file
View File

@@ -0,0 +1,95 @@
# 后端项目交接文档
## 项目信息
- 路径:/Users/zhaoguodong/Desktop/as-daycard-openapi
- 启动:`./start.sh -Dspring-boot.run.profiles=dev`(端口8084,JDK17路径在start.sh里)
- 本地打包:`export JAVA_HOME=/Users/zhaoguodong/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home && mvn clean package -DskipTests`
- MySQL:/opt/homebrew/Cellar/mysql@8.0/8.0.42/bin/mysql -uroot -pr2356510 dev-0418
- 接口文档:API_DOC.md(完整)+ THIRD_PARTY_API.md(第三方对接)
- DDL:db/full-schema.sql(52个表,完整表结构,从dev-0418导出)
## 云服务器部署
- 服务器:宝塔面板,IP见用户确认
- 数据库:jstopenapi(已建库+导入full-schema.sql+schedule-timeout-task.sql)
- MySQL权限:设为"所有人"(允许Docker容器通过host.docker.internal访问)
- Docker部署:docker-compose.yml已配好(DB_URL/REDIS_HOST用host.docker.internal,JWT/API签名密钥已生成)
- application-prod.yml:已创建(和dev相同配置,环境变量由docker-compose传入)
- Dockerfile:已改为multi-stage(JDK17),但服务器上用的是简单版(本地打包jar上传+COPY)
- 部署方式:本地mvn打包 -> scp上传jar -> docker-compose build/up
- **已知问题**:MySQL的max_connect_errors封锁Docker容器IP,需 `mysql -uroot -p -e "FLUSH HOSTS;"` 解除后重启容器
- **部署状态**:jar已上传,application-prod.yml已包含,正在解决MySQL连接封锁问题
## 已完成的功能
### 1. 橘视厅媒资接口(/manage/jetsen-media/**,JWT鉴权)
- catalog-tree/list/search/export/nav/tags
- 栏目树(一级+二级),list必须用二级栏目id(如472=3221条)
- orderinfo:"1"必须带
### 2. 第三方开放接口(/openapi/partner/**,appKey签名鉴权)
- PartnerSignFilter:MD5(appKey+timestamp+nonce+appSecret).toUpperCase(),5分钟有效+nonce防重放
- 媒资接口(/openapi/partner/media/):package/catalog-tree/list/search
- 三级授权:CONTENT(单部内容)>CATALOG(栏目)>FULL(全量),按open_api_content_package配置判定
- 天卡代销(/openapi/partner/card/):consume(消耗天卡绑定设备)/stock(库存)
- 调用日志:open_api_call_log
### 3. 代销模式
- 采购:POST /manage/partner-card/purchase(平台卡池UNUSED->RESERVED,记入partner池子AVAILABLE)
- 消耗:POST /openapi/partner/card/consume(partner池子取1张,绑定设备,RESERVED->USED,异步激活橘视厅)
- 回退:POST /manage/partner-card/revert(AVAILABLE的天卡回退,RESERVED->UNUSED,partner池子DISABLED)
- 免activationCode init:/api/jst/init不传activationCode时按设备标识查找已注册设备(代销模式)
- APK流程:consume消耗天卡 -> APK init(免activationCode) -> checkBenefits拿thirdCardNo -> 播放
### 4. 内容包管理(/manage/content-package/**,JWT鉴权)
- CRUD + fetch-contents(从橘视厅拉内容预览)+ save-from-selection(勾选内容一键保存)
- info/info-by-partner返回contentList(解析后的内容数组)
- save-from-selection支持传id覆盖更新
### 5. 接口风格统一
- 所有接口返回:{code:0, msg:"success", data:{...}}
- APK设备接口原code:200已改0,sign从顶层移入data
- UI接口原retCode:"00000000"已改{code:0,msg,data}
- 管理后台R.ok().put()全部改R.dataSuccess()(数据在data内)
- 路径统一:/sys/管理接口移到/manage/(仅login/refresh/logout保留/sys/)
- 合作伙伴管理从/openapi/partner移到/manage/partner(JWT鉴权)
- 删除冗余DayCardPoolController(和DayCardManageController冲突)
### 6. 审计日志
- audit_log表已重建(字段对齐AuditLogEntity)
- 46个@AuditAction审计点(资金/资产/设备/组织写操作)
- AuditLogAspect切面自动记录
## 数据库关键表
- 合作伙伴:open_api_partner(appKey/appSecret)
- 内容包:open_api_content_package(partnerId/catalogIds/contentCodes)
- partner天卡池:open_api_partner_card_pool(partnerId/poolId/cardCode/status)
- 调用日志:open_api_call_log
- 审计日志:audit_log(已重建)
- 天卡池:day_card_pool(UNUSED->RESERVED->USED->REUSABLE)
- 设备:device(含androidId/manufacturer/model/brand扩展字段)
## 测试验证状态
- ✅ 媒资接口(4个)+ appKey签名鉴权 + 三级授权过滤
- ✅ 采购天卡 + 消耗天卡 + 回退天卡
- ✅ 内容包配置(fetch-contents + save-from-selection)
- ✅ 禁用合作伙伴 -> 鉴权失败
- ✅ 删除内容包 -> FULL全量fallback
- ✅ 管理后台前端已适配新路径和返回格式
## 待处理事项
1. **服务器部署**:解决MySQL连接封锁后重启Docker容器,验证启动成功
2. **直营模式**(方式2):EPG调开放订单接口 -> 微信支付 -> 自动发卡(未开发)
3. **旧代码清理**:DayCardSettleController(旧分账)/MediaController(桩)/ContractServiceImpl(桩)等废弃代码
4. **Nginx反代+HTTPS**:宝塔配反代,第三方用域名调用
5. **前端菜单**:合作伙伴管理/内容包管理/天卡池管理的菜单页面
## 关键配置
- docker-compose.yml:DB_URL=jstopenapi, REDIS_HOST=host.docker.internal, JWT_SECRET=2F064BA10037477FBB3A1BF9D508AE96, API_SIGN_SECRET=5F2A2DD8BFBA4DA1B87EFBB405671B3D
- application-prod.yml:和dev相同,环境变量传入
- Druid wall filter:dev和prod都禁用(不兼容SKIP LOCKED)
- Lombok:1.18.24,必须用JDK17编译(JDK21+不兼容)
## 合作伙伴测试数据(本地dev-0418)
- 华住酒店:partnerId=0f91ea23961e4983, appKey=b9f9542e1e044466, appSecret=d117d67429b941c1bc0e0f6044e39d62
- 测试合作伙伴:partnerId=1, appKey=71df57e7e47a4a88, appSecret=512f147432124bb48b313365ee31b87b
- 内容包:id=3, name=高分3部, catalogIds=472, contentCount=3, CONTENT级别

View File

@@ -0,0 +1,7 @@
-- 设备表增加来源标识字段(在云服务器数据库 jstopenapi 上执行)
ALTER TABLE device ADD COLUMN source VARCHAR(20) DEFAULT 'DIRECT' COMMENT '设备来源: DIRECT(直销)/PARTNER(代销)';
ALTER TABLE device ADD COLUMN partner_id VARCHAR(32) DEFAULT NULL COMMENT '合作伙伴ID(代销模式)';
-- 验证
DESCRIBE device;

22
db/audit_log_rebuild.sql Normal file
View File

@@ -0,0 +1,22 @@
DROP TABLE IF EXISTS audit_log;
CREATE TABLE audit_log (
id BIGINT NOT NULL AUTO_INCREMENT,
actor_user_id BIGINT DEFAULT NULL COMMENT '操作人用户ID',
head VARCHAR(256) DEFAULT NULL COMMENT '操作标题(中文描述)',
method VARCHAR(128) DEFAULT NULL COMMENT '动作类型(如ACTCODE_GENERATE)',
api VARCHAR(256) DEFAULT NULL COMMENT 'API路径(类名#方法名)',
param VARCHAR(2000) DEFAULT NULL COMMENT '入参JSON',
result VARCHAR(500) DEFAULT NULL COMMENT '返回结果/异常',
`before` TEXT DEFAULT NULL COMMENT '变更前数据',
`after` TEXT DEFAULT NULL COMMENT '变更后数据',
reason VARCHAR(512) DEFAULT NULL COMMENT '操作原因',
ip VARCHAR(64) DEFAULT NULL COMMENT '请求IP',
device_info VARCHAR(255) DEFAULT NULL COMMENT 'User-Agent/设备信息',
ticket_no VARCHAR(64) DEFAULT NULL COMMENT '工单号',
created_at DATETIME DEFAULT NULL COMMENT '创建时间',
updated_at DATETIME DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (id),
KEY idx_actor_user_id (actor_user_id),
KEY idx_created_at (created_at),
KEY idx_method (method)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计日志';

12
db/device-info-fields.sql Normal file
View File

@@ -0,0 +1,12 @@
-- ===========================================================
-- device-info-fields.sql
-- init 接口扩展设备信息字段(对齐 App 端 20260716 需求)
-- 新增 android_id / manufacturer / model / brand
-- ===========================================================
ALTER TABLE `device`
ADD COLUMN `android_id` VARCHAR(64) DEFAULT NULL COMMENT 'Android ID(重装不变,工厂重置才变)' AFTER `device_uid`,
ADD COLUMN `manufacturer` VARCHAR(64) DEFAULT NULL COMMENT '设备厂商 Build.MANUFACTURER' AFTER `android_id`,
ADD COLUMN `model` VARCHAR(128) DEFAULT NULL COMMENT '设备型号 Build.MODEL' AFTER `manufacturer`,
ADD COLUMN `brand` VARCHAR(64) DEFAULT NULL COMMENT '设备品牌 Build.BRAND' AFTER `model`,
ADD INDEX `idx_android_id` (`android_id`);

51
db/device-tables.sql Normal file
View File

@@ -0,0 +1,51 @@
-- ============================================================
-- 补建 device + card_device_binding 表(mysql.sql 遗漏,含扩展字段)
-- 全新部署时执行此文件后,无需再执行 device-info-fields.sql
-- ============================================================
CREATE TABLE IF NOT EXISTS `device` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`store_org_id` BIGINT DEFAULT NULL COMMENT '门店组织ID',
`mac` VARCHAR(64) DEFAULT NULL COMMENT '设备MAC',
`device_sn` VARCHAR(64) DEFAULT NULL COMMENT '设备序列号',
`wifi_mac` VARCHAR(64) DEFAULT NULL COMMENT 'WiFi MAC',
`device_uid` VARCHAR(64) DEFAULT NULL COMMENT '设备唯一标识',
`android_id` VARCHAR(64) DEFAULT NULL COMMENT 'Android ID(重装不变,工厂重置才变)',
`manufacturer` VARCHAR(64) DEFAULT NULL COMMENT '设备厂商 Build.MANUFACTURER',
`model` VARCHAR(128) DEFAULT NULL COMMENT '设备型号 Build.MODEL',
`brand` VARCHAR(64) DEFAULT NULL COMMENT '设备品牌 Build.BRAND',
`identifier_strategy` VARCHAR(32) DEFAULT NULL COMMENT '标识匹配策略',
`device_name` VARCHAR(128) DEFAULT NULL COMMENT '设备名称',
`device_model` VARCHAR(128) DEFAULT NULL COMMENT '设备型号(详细)',
`ver_code` VARCHAR(32) DEFAULT NULL COMMENT '版本号',
`ver_name` VARCHAR(64) DEFAULT NULL COMMENT '版本名',
`android_version` VARCHAR(32) DEFAULT NULL COMMENT '安卓版本',
`room_no` VARCHAR(32) DEFAULT NULL COMMENT '房间号',
`last_heartbeat` DATETIME DEFAULT NULL COMMENT '最后心跳时间',
`remark` VARCHAR(256) DEFAULT NULL COMMENT '备注',
`status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/DISABLED',
`delete_flag` VARCHAR(8) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL',
`created_at` DATETIME DEFAULT NULL COMMENT '创建时间',
`updated_at` DATETIME DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_mac` (`mac`),
KEY `idx_device_uid` (`device_uid`),
KEY `idx_device_sn` (`device_sn`),
KEY `idx_android_id` (`android_id`),
KEY `idx_store_org_id` (`store_org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备表';
CREATE TABLE IF NOT EXISTS `card_device_binding` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`card_id` BIGINT DEFAULT NULL COMMENT '卡ID',
`device_id` BIGINT DEFAULT NULL COMMENT '设备ID',
`bind_source` VARCHAR(32) DEFAULT NULL COMMENT '绑定来源',
`bind_at` DATETIME DEFAULT NULL COMMENT '绑定时间',
`unbind_at` DATETIME DEFAULT NULL COMMENT '解绑时间',
`status` VARCHAR(16) NOT NULL DEFAULT 'BOUND' COMMENT '绑定状态:BOUND/UNBOUND/STANDBY/FAILURE',
`created_at` DATETIME DEFAULT NULL COMMENT '创建时间',
`updated_at` DATETIME DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_device_id` (`device_id`),
KEY `idx_card_id` (`card_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卡与设备绑定';

1013
db/full-schema.sql Normal file

File diff suppressed because it is too large Load Diff

63
db/init-sys-data.sql Normal file
View File

@@ -0,0 +1,63 @@
-- 系统初始化数据(菜单 + 角色 + 超管用户)
-- 在服务器执行:mysql -ujstopenapi -p'MAe8pALGWjE6jCwi' jstopenapi < db/init-sys-data.sql
-- 菜单
INSERT INTO `sys_menu` (`id`, `parent_id`, `name`, `url`, `perms`, `type`, `icon`, `show`, `order_num`, `created_at`, `updated_at`) VALUES
(1,0,'系统管理',NULL,NULL,0,'system','10',0,NULL,NULL),
(2,1,'管理员列表','sys/user',NULL,1,'admin','10',1,NULL,NULL),
(3,1,'角色管理','sys/role',NULL,1,'role','10',2,NULL,NULL),
(4,1,'菜单管理','sys/menu',NULL,1,'menu','10',3,NULL,NULL),
(15,2,'查看',NULL,'sys:user:list,sys:user:info',2,NULL,'10',0,NULL,NULL),
(16,2,'新增',NULL,'sys:user:save,sys:role:select',2,NULL,'10',0,NULL,NULL),
(17,2,'修改',NULL,'sys:user:update,sys:role:select',2,NULL,'10',0,NULL,NULL),
(18,2,'删除',NULL,'sys:user:delete',2,NULL,'10',0,NULL,NULL),
(19,3,'查看',NULL,'sys:role:list,sys:role:info',2,NULL,'10',0,NULL,NULL),
(20,3,'新增',NULL,'sys:role:save,sys:menu:list',2,NULL,'10',0,NULL,NULL),
(21,3,'修改',NULL,'sys:role:update,sys:menu:list',2,NULL,'10',0,NULL,NULL),
(22,3,'删除',NULL,'sys:role:delete',2,NULL,'10',0,NULL,NULL),
(23,4,'查看',NULL,'sys:menu:list,sys:menu:info',2,NULL,'10',0,NULL,NULL),
(24,4,'新增',NULL,'sys:menu:save,sys:menu:select',2,NULL,'10',0,NULL,NULL),
(25,4,'修改',NULL,'sys:menu:update,sys:menu:select',2,NULL,'10',0,NULL,NULL),
(26,4,'删除',NULL,'sys:menu:delete',2,NULL,'10',0,NULL,NULL),
(100,0,'业务管理',NULL,NULL,0,'el-icon-menu','10',1,'2026-07-12 15:57:45','2026-07-22 12:56:21'),
(101,100,'渠道管理','org/org',NULL,1,'el-icon-share','10',1,'2026-07-12 15:57:45','2026-07-12 15:57:45'),
(102,100,'门店管理','org/shop',NULL,1,'el-icon-shop','10',2,'2026-07-12 15:57:45','2026-07-12 15:57:45'),
(103,100,'设备管理','manage/device',NULL,1,'el-icon-monitor','10',3,'2026-07-12 15:57:45','2026-07-12 15:57:45'),
(104,100,'支付配置','manage/pay-config',NULL,1,'el-icon-wallet','10',4,'2026-07-12 15:57:45','2026-07-12 15:57:45'),
(105,100,'定价策略','manage/price',NULL,1,'el-icon-price-tag','10',5,'2026-07-12 15:57:45','2026-07-12 15:57:45'),
(106,100,'统计概览','manage/stats-overview',NULL,1,'el-icon-data-line','10',6,'2026-07-12 15:57:45','2026-07-12 15:57:45'),
(109,100,'天卡池管理','manage/card-pool',NULL,1,'el-icon-box','10',9,'2026-07-13 20:22:01','2026-07-13 20:22:01'),
(110,100,'天卡订单','manage/card-order',NULL,1,'el-icon-document','10',10,'2026-07-13 20:22:01','2026-07-13 20:22:01'),
(111,100,'分账接收方','manage/profit-receiver',NULL,1,'el-icon-connection','10',11,'2026-07-14 10:03:59','2026-07-14 10:03:59'),
(112,100,'退款管理','manage/refund',NULL,1,'el-icon-refresh-left','10',12,'2026-07-14 10:03:59','2026-07-14 10:03:59'),
(113,100,'天卡激活码','manage/activation-code','',1,'Ticket','10',13,'2026-07-14 15:59:35','2026-07-14 15:59:35'),
(114,100,'分润规则','manage/profit-rule','',1,'Coin','10',14,'2026-07-14 15:59:35','2026-07-14 15:59:35'),
(115,100,'分账记录','manage/profit-sharing','',1,'Wallet','10',15,'2026-07-14 15:59:35','2026-07-14 15:59:35'),
(116,0,'媒资管理',NULL,'',0,'Film','10',2,'2026-07-21 18:00:28','2026-07-21 18:00:28'),
(117,116,'橘视厅媒资','jetsen-media/list','',1,'Film','10',1,'2026-07-21 18:00:28','2026-07-21 18:00:28'),
(118,121,'合作伙伴管理','manage/partner','',1,'Connection','10',16,'2026-07-22 12:56:21','2026-07-22 13:43:39'),
(119,121,'内容包配置','manage/content-package','',1,'Files','10',17,'2026-07-22 12:56:21','2026-07-22 13:43:39'),
(120,121,'天卡库存','manage/partner-card','',1,'ShoppingCart','10',18,'2026-07-22 12:56:21','2026-07-22 14:59:52'),
(121,0,'第三方接口管理',NULL,'',0,'Setting','10',19,'2026-07-22 13:43:39','2026-07-22 13:43:39');
-- 角色
INSERT INTO `sys_role` (`id`, `role_name`, `remark`, `status`, `created_at`, `updated_at`) VALUES
(1,'超级管理员','平台超级管理员','1','2026-07-12 15:25:13',NULL),
(2,'渠道','渠道角色','1','2026-07-12 15:25:13',NULL),
(3,'代理商','代理商角色','1','2026-07-12 15:25:13',NULL),
(4,'门店','门店角色','1','2026-07-12 15:25:13',NULL);
-- 超管用户(admin/admin,org_id=NULL 即超管)
INSERT INTO `sys_user` (`id`, `username`, `password`, `salt`, `email`, `mobile`, `status`, `org_id`, `created_at`) VALUES
(1,'admin','9ec9750e709431dad22365cabc5c625482e574c74adaebba7dd02f1129e4ce1d','YzcmCZNvbXocrsz9dm8e','admin@as.io','13612345678',1,NULL,'2026-07-12 15:25:13');
-- 用户角色关联
INSERT INTO `sys_user_role` (`id`, `user_id`, `role_id`) VALUES
(1,1,1);
-- 角色菜单关联(超管通过 org_id=NULL 已绕过,此数据仅备份)
INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES
(1,1,1),(2,1,2),(3,1,3),(4,1,4),(5,1,15),(6,1,16),(7,1,17),(8,1,18),
(9,1,19),(10,1,20),(11,1,21),(12,1,22),(13,1,23),(14,1,24),(15,1,25),(16,1,26),
(32,1,100),(33,1,101),(34,1,102),(35,1,103),(36,1,104),(37,1,105),(38,1,106),
(41,1,109),(42,1,110),(43,1,111),(44,1,112);

146
db/mysql.sql Normal file
View File

@@ -0,0 +1,146 @@
-- ============================================================
-- 天卡 OpenAPI 数据库 DDL 脚本
-- 数据库: MySQL 8.0+
-- 字符集: utf8mb4
-- 说明: 字段命名与 Entity/Service 代码严格对齐
-- 通用字段: created_at/updated_at/delete_flag
-- 状态值: 大写英文枚举字符串
-- ============================================================
-- -----------------------------------------------------------
-- 1. 合作伙伴表
-- Entity: OpenApiPartnerEntity
-- 对应 Service 列名: partner_id, app_key, name, status, delete_flag, created_at
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS `open_api_partner` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`partner_id` VARCHAR(64) NOT NULL COMMENT '合作伙伴ID',
`app_key` VARCHAR(64) NOT NULL COMMENT '应用Key',
`app_secret` VARCHAR(128) NOT NULL COMMENT '应用密钥',
`name` VARCHAR(100) DEFAULT NULL COMMENT '合作伙伴名称',
`status` VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/DISABLED',
`api_permissions` TEXT DEFAULT NULL COMMENT 'API权限(JSON)',
`rate_limit` INT NOT NULL DEFAULT 100 COMMENT '速率限制(每秒请求数)',
`delete_flag` VARCHAR(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_app_key` (`app_key`),
UNIQUE KEY `uk_partner_id` (`partner_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开放API合作伙伴';
-- -----------------------------------------------------------
-- 2. 天卡池表
-- Entity: DayCardPoolEntity
-- 对应 Service 列名: card_code, partner_id, status, order_no, device_id, delete_flag, created_at, updated_at
-- 对应 Mapper XML: selectOneForUpdateSkipLocked (status, delete_flag, partner_id)
-- 状态枚举: UNUSED/RESERVED/USED
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS `day_card_pool` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`card_code` VARCHAR(128) NOT NULL COMMENT '卡密(导入的激活码)',
`third_party_ref` VARCHAR(128) DEFAULT NULL COMMENT '第三方引用标识',
`third_card_no` VARCHAR(128) DEFAULT NULL COMMENT '传第三方的唯一卡号',
`card_type` VARCHAR(20) NOT NULL DEFAULT 'DAY' COMMENT '卡类型:DAY',
`partner_id` BIGINT DEFAULT NULL COMMENT '所属合作伙伴ID',
`status` VARCHAR(20) NOT NULL DEFAULT 'UNUSED' COMMENT '状态:UNUSED/RESERVED/USED',
`order_no` VARCHAR(32) DEFAULT NULL COMMENT '关联订单号',
`device_id` BIGINT DEFAULT NULL COMMENT '关联设备ID',
`import_batch_id` BIGINT DEFAULT NULL COMMENT '导入批次ID',
`expire_at` DATETIME DEFAULT NULL COMMENT '卡过期时间',
`used_at` DATETIME DEFAULT NULL COMMENT '使用时间',
`delete_flag` VARCHAR(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_card_code` (`card_code`),
KEY `idx_partner_status` (`partner_id`, `status`),
KEY `idx_order_no` (`order_no`),
KEY `idx_device_id` (`device_id`),
KEY `idx_import_batch_id` (`import_batch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天卡池';
-- -----------------------------------------------------------
-- 3. 天卡订单表
-- Entity: DayCardOrderEntity
-- 对应 Service 列名: order_no, partner_id, device_id, status, amount, pay_no, pay_amount, pay_time, expire_time, cancel_time, delete_flag, created_at, updated_at
-- 状态枚举: CREATED/PAYING/PAID/PAY_FAILED/CANCELLED
-- MediaServiceImpl SQL: device_id, status, expire_time
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS `day_card_order` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`order_no` VARCHAR(32) NOT NULL COMMENT '订单号',
`partner_id` BIGINT DEFAULT NULL COMMENT '合作伙伴ID',
`device_id` BIGINT DEFAULT NULL COMMENT '设备ID',
`card_no` VARCHAR(128) DEFAULT NULL COMMENT '卡号(支付后写入,关联day_card_pool.card_code)',
`status` VARCHAR(20) NOT NULL DEFAULT 'CREATED' COMMENT '订单状态:CREATED/PAYING/PAID/PAY_FAILED/CANCELLED',
`amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额(元)',
`pay_no` VARCHAR(64) DEFAULT NULL COMMENT '支付流水号',
`pay_amount` DECIMAL(10,2) DEFAULT NULL COMMENT '实际支付金额(元)',
`pay_time` DATETIME DEFAULT NULL COMMENT '支付时间',
`expire_time` DATETIME DEFAULT NULL COMMENT '订单过期时间(15分钟)',
`cancel_time` DATETIME DEFAULT NULL COMMENT '取消时间',
`delete_flag` VARCHAR(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_device_status` (`device_id`, `status`),
KEY `idx_status_expire` (`status`, `expire_time`),
KEY `idx_partner_status` (`partner_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天卡订单表';
-- -----------------------------------------------------------
-- 4. 天卡导入批次表
-- Entity: DayCardImportBatchEntity
-- 状态枚举: PROCESSING/SUCCEEDED/PARTIAL_FAILED/FAILED
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS `day_card_import_batch` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`batch_no` VARCHAR(64) NOT NULL COMMENT '批次号',
`partner_id` BIGINT DEFAULT NULL COMMENT '合作伙伴ID',
`status` VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT '状态:PROCESSING/SUCCEEDED/PARTIAL_FAILED/FAILED',
`total_count` INT NOT NULL DEFAULT 0 COMMENT '总数量',
`success_count` INT NOT NULL DEFAULT 0 COMMENT '成功数量',
`fail_count` INT NOT NULL DEFAULT 0 COMMENT '失败数量',
`result_report_url` VARCHAR(500) DEFAULT NULL COMMENT '结果报告URL',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`created_by` BIGINT DEFAULT NULL COMMENT '创建人',
`delete_flag` VARCHAR(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_batch_no` (`batch_no`),
KEY `idx_partner_id` (`partner_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天卡导入批次';
-- -----------------------------------------------------------
-- 5. 天卡设备激活码表
-- Entity: DayCardActivationCodeEntity
-- 用途: 管理后台按门店生成激活码,App 端 init 时输入激活码完成设备注册绑定
-- 状态枚举: ACTIVE/DISABLED
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS `day_card_activation_code` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`code` VARCHAR(32) NOT NULL COMMENT '激活码(唯一)',
`channel_org_id` BIGINT NOT NULL COMMENT '渠道组织ID',
`store_org_id` BIGINT NOT NULL COMMENT '门店组织ID',
`status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/DISABLED',
`max_device_count` INT NOT NULL DEFAULT 0 COMMENT '最大可激活设备数(0=不限)',
`activated_count` INT NOT NULL DEFAULT 0 COMMENT '已激活设备数',
`remark` VARCHAR(255) DEFAULT NULL COMMENT '备注',
`delete_flag` VARCHAR(10) NOT NULL DEFAULT 'UNDEL' COMMENT '删除标记:UNDEL/DEL',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`),
KEY `idx_store_org_id` (`store_org_id`),
KEY `idx_channel_org_id` (`channel_org_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天卡设备激活码';

31
db/openapi_media.sql Normal file
View File

@@ -0,0 +1,31 @@
-- 内容包授权配置(一个partner一条记录,三级fallback:content_codes > catalog_ids > 全量)
CREATE TABLE IF NOT EXISTS open_api_content_package (
id BIGINT NOT NULL AUTO_INCREMENT,
partner_id VARCHAR(32) NOT NULL COMMENT '合作伙伴ID',
name VARCHAR(128) DEFAULT NULL COMMENT '内容包名称',
catalog_ids VARCHAR(2000) DEFAULT NULL COMMENT '授权栏目ID列表(逗号分隔,二级栏目id如472)',
content_codes TEXT DEFAULT NULL COMMENT '授权内容code列表(逗号分隔),非空时优先按内容过滤',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/DISABLED',
delete_flag VARCHAR(8) NOT NULL DEFAULT 'UNDEL',
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_partner_id (partner_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开放API内容包授权';
-- 第三方调用日志(可溯源)
CREATE TABLE IF NOT EXISTS open_api_call_log (
id BIGINT NOT NULL AUTO_INCREMENT,
partner_id VARCHAR(32) DEFAULT NULL COMMENT '合作伙伴ID',
app_key VARCHAR(32) DEFAULT NULL,
api VARCHAR(256) DEFAULT NULL COMMENT '请求路径',
http_method VARCHAR(16) DEFAULT NULL,
params VARCHAR(2000) DEFAULT NULL COMMENT '请求参数',
result_code INT DEFAULT NULL COMMENT '返回业务code',
ip VARCHAR(64) DEFAULT NULL,
cost_ms INT DEFAULT NULL COMMENT '耗时毫秒',
created_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_partner_id (partner_id),
KEY idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开放API调用日志';

View File

@@ -0,0 +1,20 @@
-- 合作伙伴天卡池(代销模式:第三方采购的天卡存这里,消耗时绑定设备)
CREATE TABLE IF NOT EXISTS open_api_partner_card_pool (
id BIGINT NOT NULL AUTO_INCREMENT,
partner_id VARCHAR(32) NOT NULL COMMENT '合作伙伴ID(open_api_partner.partner_id)',
pool_id BIGINT NOT NULL COMMENT 'day_card_pool.id 天卡池主键',
card_code VARCHAR(64) DEFAULT NULL COMMENT '天卡卡密',
third_card_no VARCHAR(64) DEFAULT NULL COMMENT '橘视厅第三方卡号',
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE可用/CONSUMED已消耗/DISABLED已禁用',
device_id BIGINT DEFAULT NULL COMMENT '消耗后绑定的设备ID',
channel_org_id BIGINT DEFAULT NULL COMMENT '渠道组织ID(sys_org.org_id)',
purchase_batch VARCHAR(32) DEFAULT NULL COMMENT '采购批次号',
consumed_at DATETIME DEFAULT NULL COMMENT '消耗时间',
delete_flag VARCHAR(8) NOT NULL DEFAULT 'UNDEL',
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_partner_id (partner_id),
KEY idx_partner_status (partner_id, status),
KEY idx_pool_id (pool_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='合作伙伴天卡池(代销模式)';

View File

@@ -0,0 +1,13 @@
-- ===========================================================
-- price-config-3layer.sql
-- 定价策略升级为 3 层维度:门店配置价 > 渠道配置价 > 平台默认价兜底
-- 同时取消时长约束(天卡固定 24 小时,不再按配置存时长)
-- ===========================================================
-- 1. 新增门店组织ID列(空=渠道级或平台级)
ALTER TABLE `day_card_price_config`
ADD COLUMN `store_org_id` BIGINT DEFAULT NULL COMMENT '门店组织ID(空=渠道级/平台级)' AFTER `channel_org_id`,
ADD INDEX `idx_store_org_id` (`store_org_id`);
-- 2. 取消时长约束,删除 duration_hours 列
ALTER TABLE `day_card_price_config` DROP COLUMN `duration_hours`;

View File

@@ -0,0 +1,61 @@
-- ============================================================
-- 1. 还原测试卡(在云服务器数据库 jstopenapi 上执行)
-- ============================================================
-- 1.1 重置平台天卡池:USED -> RESERVED,清除激活状态和设备绑定
UPDATE day_card_pool
SET status = 'RESERVED',
activate_status = NULL,
activate_error = NULL,
device_id = NULL,
used_at = NULL,
expire_at = NULL,
activated_at = NULL,
updated_at = NOW()
WHERE third_card_no = 'DKMMqjJKgpdwUahip5l4hGzjS'
AND status = 'USED';
-- 1.2 重置partner天卡池:CONSUMED -> AVAILABLE,清除设备绑定
UPDATE open_api_partner_card_pool
SET status = 'AVAILABLE',
device_id = NULL,
channel_org_id = NULL,
consumed_at = NULL,
updated_at = NOW()
WHERE third_card_no = 'DKMMqjJKgpdwUahip5l4hGzjS'
AND status = 'CONSUMED';
-- 1.3 验证还原结果
SELECT 'day_card_pool' AS tbl, id, card_code, third_card_no, status, activate_status, device_id
FROM day_card_pool WHERE third_card_no = 'DKMMqjJKgpdwUahip5l4hGzjS';
SELECT 'partner_card_pool' AS tbl, id, partner_id, pool_id, card_code, third_card_no, status, device_id
FROM open_api_partner_card_pool WHERE third_card_no = 'DKMMqjJKgpdwUahip5l4hGzjS';
-- 1.4 清除Redis缓存(在服务器上执行,防止Token缓存和激活锁干扰重试)
-- docker exec daycard-redis redis-cli DEL "api:thirdparty:activate:DKMMqjJKgpdwUahip5l4hGzjS"
-- docker exec daycard-redis redis-cli KEYS "api:thirdparty:activate:lock:*" | xargs -I{} docker exec daycard-redis redis-cli DEL {}
-- docker exec daycard-redis redis-cli KEYS "daycard:partner:consume:*" | xargs -I{} docker exec daycard-redis redis-cli DEL {}
-- ============================================================
-- 2. 导入橘视厅配置(在云服务器数据库 jstopenapi 上执行)
-- ============================================================
-- sys_config 表结构: id(PK), param_key(UNIQUE), param_value(varchar2000), status(tinyint default 1), remark(varchar500)
-- param_key 有 UNIQUE 约束,ON DUPLICATE KEY UPDATE 自动处理已存在情况
INSERT INTO sys_config (param_key, param_value, status, remark)
VALUES (
'JST_PLATFORM_CONFIG',
'{"jushitingBaseUrl":"https://api.future.jetsentv.com","vipQueryBaseUrl":"https://api.future.jetsentv.com","jushitingTenant":"huashitv","jushitingAccesser":"JSTASJD","jushitingBusinessLine":"4100450011381755998420012","jushitingServiceComboCode":"1100121010200494755850172","jushitingLoginRsaPublicKey":"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtlfaNsJwQ8wuVdITMLJPrXMeD9idJ0aTziOgkZZhigwzhyPNDAqw+nCKeJGGoTJyhbjPXKtnyI8eNpj+xhCCMXUD3BxOVMFWBhFxzIl83IQJG43mPRXwsJrI4oCYDaKv5aeZZBqF0OLPglXU+q19/28ltONvxcKp8YxXagmixzz1SDgLITM3s3ExqiaScK1dcpPtXbCpI4GXPP9l0ekkCBAo/d1oQqbxHvdE5ufAhEZ042k5tVHUgX9cUoedA9F2JcPhc+zUNCJUAQLvfVXxqNdhwxA4TY+64OX2iSje/cTqxp+4RcrX4rbUIYZitVQCRexZjKz7Yd/KXp8CKCho6wIDAQAB","jushitingActivateSignKey":"hhDEHY1qC4r","vipQuerySignKey":"c25svtwbTyCE"}',
1,
'聚视厅/橘视厅第三方平台配置(JSON)'
)
ON DUPLICATE KEY UPDATE
param_value = VALUES(param_value),
status = 1,
remark = VALUES(remark);
-- 验证
SELECT param_key, param_value, remark FROM sys_config WHERE param_key = 'JST_PLATFORM_CONFIG';

View File

@@ -0,0 +1,21 @@
-- ===========================================================
-- schedule-timeout-task.sql
-- 修复:天卡订单超时取消任务未被调度,导致 RESERVED 卡永不释放
-- 1) 注册 dayCardOrderTimeoutTask 到 schedule_job(每 2 分钟)
-- 2) 清理当前卡死的 RESERVED 卡与对应过期 PENDING 订单
-- 注:新增调度任务需重启后端才会被 Quartz 加载生效
-- ===========================================================
-- 1. 注册超时取消任务(status=0 正常运行)
INSERT INTO schedule_job (bean_name, params, cron_expression, status, remark, create_time)
VALUES ('dayCardOrderTimeoutTask', '', '0 */2 * * * ?', 0, '天卡订单超时取消-释放预占卡', NOW());
-- 2. 释放卡死的 RESERVED 卡 -> UNUSED
UPDATE day_card_pool
SET status = 'UNUSED', order_no = NULL, device_id = NULL, updated_at = NOW()
WHERE status = 'RESERVED' AND delete_flag = 'UNDEL';
-- 3. 取消过期未支付订单 PENDING -> CANCELLED
UPDATE day_card_order
SET status = 'CANCELLED', cancel_time = NOW(), updated_at = NOW()
WHERE status = 'PENDING' AND delete_flag = 'UNDEL' AND expire_time < NOW();

42
docker-compose.yml Normal file
View File

@@ -0,0 +1,42 @@
version: "3.8"
services:
daycard-openapi:
build: .
image: daycard-openapi:latest
container_name: daycard-openapi
ports:
- "8084:8084"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- SPRING_PROFILES_ACTIVE=prod
- SERVER_PORT=8084
- TZ=Asia/Shanghai
- DB_URL=jdbc:mysql://host.docker.internal:3306/jstopenapi?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
- DB_USERNAME=jstopenapi
- DB_PASSWORD=MAe8pALGWjE6jCwi
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_OPEN=true
- JWT_SECRET=2F064BA10037477FBB3A1BF9D508AE96
- JWT_EXPIRE=604800
- API_SIGN_SECRET=5F2A2DD8BFBA4DA1B87EFBB405671B3D
- DAYCARD_ORDER_TIMEOUT=15
- DAYCARD_VALID_HOURS=24
- WECHAT_PAY_APP_ID=
- WECHAT_PAY_MCH_ID=
- WECHAT_PAY_API_V3_KEY=
- WECHAT_PAY_CERT_SERIAL_NO=
- WECHAT_PAY_PRIVATE_KEY_PATH=
- WECHAT_PAY_NOTIFY_URL=
depends_on:
- redis
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: daycard-redis
ports:
- "6379:6379"
restart: unless-stopped

270
pom.xml Normal file
View File

@@ -0,0 +1,270 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>as</groupId>
<artifactId>daycard-openapi</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<description>asDaycardOpenAPI</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.6</version>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<mybatisplus.version>3.3.1</mybatisplus.version>
<mysql.version>8.0.28</mysql.version>
<druid.version>1.1.13</druid.version>
<quartz.version>2.3.0</quartz.version>
<commons.lang.version>2.6</commons.lang.version>
<commons.fileupload.version>1.2.2</commons.fileupload.version>
<commons.io.version>2.5</commons.io.version>
<commons.codec.version>1.10</commons.codec.version>
<commons.configuration.version>1.10</commons.configuration.version>
<shiro.version>1.9.0</shiro.version>
<jwt.version>0.7.0</jwt.version>
<swagger.version>2.7.0</swagger.version>
<joda.time.version>2.9.9</joda.time.version>
<gson.version>2.8.5</gson.version>
<fastjson.version>1.2.79</fastjson.version>
<hutool.version>4.1.1</hutool.version>
<poi.version>4.1.2</poi.version>
<lombok.version>1.18.30</lombok.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatisplus.version}</version>
<exclusions>
<exclusion>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<version>${quartz.version}</version>
<exclusions>
<exclusion>
<groupId>com.mchange</groupId>
<artifactId>c3p0</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>${commons.lang.version}</version>
</dependency>
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>${commons.fileupload.version}</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>${commons.io.version}</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>${commons.codec.version}</version>
</dependency>
<dependency>
<groupId>commons-configuration</groupId>
<artifactId>commons-configuration</artifactId>
<version>${commons.configuration.version}</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>${shiro.version}</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>${shiro.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>${swagger.version}</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>${swagger.version}</version>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>${joda.time.version}</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>${gson.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>${poi.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
<!-- 微信支付 SDK -->
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-java</artifactId>
<version>0.2.14</version>
</dependency>
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>daycard-openapi</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<fork>true</fork>
<mainClass>io.as.AsApplication</mainClass>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>public</id>
<name>aliyun nexus</name>
<url>https://maven.aliyun.com/repository/public/</url>
<releases>
<enabled>true</enabled>
</releases>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>public</id>
<name>aliyun nexus</name>
<url>https://maven.aliyun.com/repository/public/</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</project>

View File

@@ -0,0 +1,18 @@
package io.as;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* 天卡扫码付费开放API 启动类
*/
@SpringBootApplication
@EnableAsync
public class AsApplication {
public static void main(String[] args) {
SpringApplication.run(AsApplication.class, args);
}
}

View File

@@ -0,0 +1,22 @@
package io.as.common.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 审计日志注解:标记需写入 audit_log 的操作(分配/销售/转合同/绑定/解绑/激活校验/迁移/设备转门店/禁用等)
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuditAction {
/** 动作类型:如 ALLOCATE、TRANSFER、ORG_*、CARD_*、DEVICE_*、DEVICE_TRANSFER_STORE 等 */
String value();
/** 中文描述,如 分配/销售、转合同 */
String description() default "";
}

View File

@@ -0,0 +1,15 @@
package io.as.common.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SysLog {
String value() default "";
}

View File

@@ -0,0 +1,121 @@
package io.as.common.aspect;
import com.google.gson.Gson;
import io.as.common.utils.HttpContextUtils;
import org.apache.commons.lang.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* 在 Controller 方法执行后把元数据放入 request;sys_api_log 在 ApiJstCallLogFilter#finally
* 中写入。POST/PUT/PATCH 的 body 由 Filter 在进链前整段预读,见 {@link #ATTR_PREREAD_REQUEST_BODY}。
* <p>入站 {@code /api/jst/*} 与其它 {@code io.as.modules.openapi.controller} 接口均落 sys_api_log;
* {@code POST /api/jst/heartBeat} 不落库(轮询频繁);{@code POST /api/jst/tracking} 仅落 device_tracking_log。</p>
*/
@Aspect
@Component
public class ApiLogAspect {
public static final String ATTR_SKIP_SYS_API_LOG = "io.as.api.SYS_API_LOG_SKIP";
public static final String ATTR_PENDING_SYS_API_LOG = "io.as.api.SYS_API_LOG_PENDING";
/** Filter 在进链前完整读取的 body,落 sys_api_log 时优先用,避免与 ContentCaching 时序/读空冲突 */
public static final String ATTR_PREREAD_REQUEST_BODY = "io.as.api.PREREAD_REQUEST_BODY";
/** 供 Filter 落库;params 在 Filter 用 body 字节 */
public static final class PendingSysApiLog {
public long durationMs;
public String resultJson;
public String apiName;
public String methodFull;
/** body 仍为空时退路(可能少于原始 JSON) */
public String gsonArgsFallback;
}
private static final Gson GSON = new Gson();
@Pointcut("execution(* io.as.modules.openapi.controller..*.*(..))")
public void logPointCut() {
}
@Around("logPointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
if (shouldSkipSysApiLogForJstInbound(request)) {
request.setAttribute(ATTR_SKIP_SYS_API_LOG, Boolean.TRUE);
return point.proceed();
}
long beginTime = System.currentTimeMillis();
Object result = point.proceed();
long time = System.currentTimeMillis() - beginTime;
PendingSysApiLog pending = new PendingSysApiLog();
pending.durationMs = time;
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
pending.apiName = method.getName();
String className = point.getTarget().getClass().getName();
pending.methodFull = className + "." + signature.getName() + "()";
try {
pending.resultJson = GSON.toJson(result);
} catch (Exception e) {
pending.resultJson = null;
}
pending.gsonArgsFallback = gsonToFilteredArgsJson(point.getArgs());
request.setAttribute(ATTR_PENDING_SYS_API_LOG, pending);
return result;
}
/** 心跳/埋点:不落 sys_api_log(埋点数据写入 device_tracking_log) */
private static boolean shouldSkipSysApiLogForJstInbound(HttpServletRequest request) {
String rel = requestPathWithoutContext(request);
return "/api/jst/heartBeat".equals(rel) || "/api/jst/tracking".equals(rel);
}
private static String requestPathWithoutContext(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri == null) {
return "";
}
String ctx = request.getContextPath() == null ? "" : request.getContextPath();
return uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
}
private static String gsonToFilteredArgsJson(Object[] args) {
List<Object> filteredArgs = new ArrayList<>();
if (args != null) {
for (Object arg : args) {
if (arg != null && !(arg instanceof HttpServletRequest) && !(arg instanceof HttpServletResponse)) {
filteredArgs.add(arg);
}
}
}
return GSON.toJson(filteredArgs);
}
public static Charset requestCharsetOrUtf8(HttpServletRequest r) {
if (r == null) {
return StandardCharsets.UTF_8;
}
String enc = r.getCharacterEncoding();
if (StringUtils.isNotBlank(enc)) {
try {
return Charset.forName(enc);
} catch (Exception ignored) {
}
}
return StandardCharsets.UTF_8;
}
}

View File

@@ -0,0 +1,128 @@
package io.as.common.aspect;
import com.google.gson.Gson;
import io.as.common.annotation.AuditAction;
import io.as.common.utils.HttpContextUtils;
import io.as.common.utils.IPUtils;
import io.as.modules.sys.entity.AuditLogEntity;
import io.as.modules.sys.entity.SysUserEntity;
import io.as.modules.sys.service.AuditLogService;
import org.apache.shiro.SecurityUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Date;
/**
* 审计日志切面:将标记了 @AuditAction 的接口调用记录到 audit_log 表
* 用于:分配/销售、转合同、绑定、解绑、激活校验、迁移、设备转门店、禁用等
*/
@Aspect
@Component
public class AuditLogAspect {
@Autowired
private AuditLogService auditLogService;
private static final Gson GSON = new Gson();
private static final int PARAM_MAX_LENGTH = 2000;
private static final int RESULT_MAX_LENGTH = 500;
@Pointcut("@annotation(io.as.common.annotation.AuditAction)")
public void auditPointCut() {
}
@Around("auditPointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
AuditAction auditAction = method.getAnnotation(AuditAction.class);
if (auditAction == null) {
return point.proceed();
}
String api = point.getTarget().getClass().getSimpleName() + "#" + method.getName();
String paramJson = toParamJson(point.getArgs());
String result = "success";
try {
Object ret = point.proceed();
if (ret != null && ret.toString().length() <= RESULT_MAX_LENGTH) {
result = ret.toString();
}
return ret;
} catch (Throwable t) {
result = "exception: " + (t.getMessage() != null ? t.getMessage() : t.getClass().getSimpleName());
if (result.length() > RESULT_MAX_LENGTH) {
result = result.substring(0, RESULT_MAX_LENGTH);
}
throw t;
} finally {
saveAuditLog(auditAction, api, paramJson, result, getRequest());
}
}
private void saveAuditLog(AuditAction auditAction, String api, String paramJson, String result, HttpServletRequest request) {
try {
AuditLogEntity log = new AuditLogEntity();
log.setHead(auditAction.description() != null && !auditAction.description().isEmpty()
? auditAction.description() : auditAction.value());
log.setMethod(auditAction.value());
log.setApi(api);
log.setParam(paramJson != null && paramJson.length() > PARAM_MAX_LENGTH
? paramJson.substring(0, PARAM_MAX_LENGTH) : paramJson);
log.setResult(result);
try {
Object principal = SecurityUtils.getSubject().getPrincipal();
if (principal instanceof SysUserEntity) {
log.setActorUserId(((SysUserEntity) principal).getId());
}
} catch (Exception ignored) {
}
if (request != null) {
log.setIp(IPUtils.getIpAddr(request));
String ua = request.getHeader("User-Agent");
if (ua != null && ua.length() > 255) {
ua = ua.substring(0, 255);
}
log.setDeviceInfo(ua);
}
Date now = new Date();
log.setCreatedAt(now);
log.setUpdatedAt(now);
auditLogService.save(log);
} catch (Exception e) {
// 审计写库失败不影响主流程
org.slf4j.LoggerFactory.getLogger(AuditLogAspect.class).warn("audit_log save fail: {}", e.getMessage());
}
}
private String toParamJson(Object[] args) {
if (args == null || args.length == 0) {
return null;
}
try {
// 简单序列化;可扩展为脱敏 password 等字段
return GSON.toJson(args);
} catch (Exception e) {
return "[serialize error]";
}
}
private HttpServletRequest getRequest() {
try {
return HttpContextUtils.getHttpServletRequest();
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,38 @@
package io.as.common.aspect;
import io.as.common.exception.ASException;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
/**
* Redis切面处理类
*
* @author Mark sunlightcs@gmail.com
*/
@Aspect
@Configuration
public class RedisAspect {
private Logger logger = LoggerFactory.getLogger(getClass());
//是否开启redis缓存 true开启 false关闭
@Value("${spring.redis.open: false}")
private boolean open;
@Around("execution(* io.as.common.utils.RedisUtils.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable {
Object result = null;
if(open){
try{
result = point.proceed();
}catch (Exception e){
logger.error("redis error", e);
throw new ASException("Redis服务异常");
}
}
return result;
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,52 @@
package io.as.common.exception;
/**
* 自定义异常
*
* @author Mark sunlightcs@gmail.com
*/
public class ASException extends RuntimeException {
private static final long serialVersionUID = 1L;
private String msg;
private int code = 500;
public ASException(String msg) {
super(msg);
this.msg = msg;
}
public ASException(String msg, Throwable e) {
super(msg, e);
this.msg = msg;
}
public ASException(String msg, int code) {
super(msg);
this.msg = msg;
this.code = code;
}
public ASException(String msg, int code, Throwable e) {
super(msg, e);
this.msg = msg;
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
}

View File

@@ -0,0 +1,66 @@
package io.as.common.exception;
import io.as.common.utils.R;
import org.apache.shiro.authz.AuthorizationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;
/**
* 异常处理器
*
* @author Mark sunlightcs@gmail.com
*/
@RestControllerAdvice
public class ASExceptionHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
/**
* 处理自定义异常
* 显式标记事务回滚,确保业务异常时数据库操作能正确回滚
*/
@ExceptionHandler(ASException.class)
public R handleASException(ASException e){
try {
org.springframework.transaction.TransactionStatus status = TransactionAspectSupport.currentTransactionStatus();
if (status != null) {
status.setRollbackOnly();
}
} catch (Exception ignored) {
// 无活跃事务时忽略
}
R r = new R();
r.put("code", e.getCode());
r.put("msg", e.getMessage());
r.put("data", new java.util.HashMap<>());
return r;
}
@ExceptionHandler(NoHandlerFoundException.class)
public R handlerNoFoundException(Exception e) {
logger.error(e.getMessage(), e);
return R.error(404, "路径不存在,请检查路径是否正确");
}
@ExceptionHandler(DuplicateKeyException.class)
public R handleDuplicateKeyException(DuplicateKeyException e){
logger.error(e.getMessage(), e);
return R.error("数据库中已存在该记录");
}
@ExceptionHandler(AuthorizationException.class)
public R handleAuthorizationException(AuthorizationException e){
logger.error(e.getMessage(), e);
return R.error("没有权限,请联系管理员授权");
}
@ExceptionHandler(Exception.class)
public R handleException(Exception e){
logger.error(e.getMessage(), e);
return R.error();
}
}

View File

@@ -0,0 +1,65 @@
package io.as.common.utils;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.core.type.filter.AssignableTypeFilter;
import java.lang.annotation.Annotation;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 查询特定的类,该工具下的方法应该在程序启动完成之后才可以使用
*/
public class BeanFindUtils {
private static ClassPathScanningCandidateComponentProvider scanner;
/**
* 查询指定了特定注解的类
* @param basePackage 类所存放的包名
* @param annotation 特定的注解
* @return 目标类
*/
public static Set<Class<?>> findAnnotation(String basePackage, Class<? extends Annotation> annotation) {
ClassPathScanningCandidateComponentProvider scanner = getInstance();
scanner.addIncludeFilter(new AnnotationTypeFilter(annotation));
return getClasses(basePackage, scanner);
}
/**
* 查询实现了特定接口的类
* @param basePackage 类所存放的包名
* @param interfaceClass 特定的接口
* @return 目标类
*/
public static Set<Class<?>> findInterface(String basePackage, Class<?> interfaceClass) {
ClassPathScanningCandidateComponentProvider scanner = getInstance();
scanner.addIncludeFilter(new AssignableTypeFilter(interfaceClass));
return getClasses(basePackage, scanner);
}
private static Set<Class<?>> getClasses(String basePackage, ClassPathScanningCandidateComponentProvider scanner) {
Set<BeanDefinition> beanDefinitions = scanner.findCandidateComponents(basePackage);
return beanDefinitions.stream()
.map(BeanDefinition::getBeanClassName)
.map(className -> {
try {
return Class.forName(className);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
})
.collect(Collectors.toSet());
}
private static ClassPathScanningCandidateComponentProvider getInstance(){
if (scanner == null){
scanner = new ClassPathScanningCandidateComponentProvider(false);
}
return scanner;
}
}

View File

@@ -0,0 +1,18 @@
package io.as.common.utils;
/**
* 系统参数相关Key
*
* @author Mark sunlightcs@gmail.com
*/
public class ConfigConstant {
/**
* 云存储配置KEY
*/
public final static String CLOUD_STORAGE_CONFIG_KEY = "CLOUD_STORAGE_CONFIG_KEY";
/**
* 橘视厅/聚视厅第三方平台配置KEY(param_value 为 JSON)
*/
public final static String JST_PLATFORM_CONFIG = "JST_PLATFORM_CONFIG";
}

View File

@@ -0,0 +1,146 @@
package io.as.common.utils;
import io.as.common.validator.group.QcloudGroup;
import java.util.Optional;
import java.util.stream.Stream;
/**
* 常量
*
* @author Mark sunlightcs@gmail.com
*/
public class Constant {
/**
* 超级管理员ID
*/
public static final int SUPER_ADMIN = 1;
/**
* 超级管理员ID数组
*/
public static final long[] SUPER_ADMIN_IDS = {1};
/**
* 判断是否为超级管理员(根据 orgId 是否为 null 判断)
*/
public static boolean isSuperAdmin(Long orgId) {
return orgId == null;
}
/**
* 当前页码
*/
public static final String PAGE = "page";
/**
* 每页显示记录数
*/
public static final String LIMIT = "limit";
/**
* 排序字段
*/
public static final String ORDER_FIELD = "sidx";
/**
* 排序方式
*/
public static final String ORDER = "order";
/**
* 升序
*/
public static final String ASC = "asc";
/**
* 菜单类型
*
* @author chenshun
* @email sunlightcs@gmail.com
* @date 2016年11月15日 下午1:24:29
*/
public enum MenuType {
/**
* 目录
*/
CATALOG(0),
/**
* 菜单
*/
MENU(1),
/**
* 按钮
*/
BUTTON(2);
private int value;
MenuType(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
/**
* 定时任务状态
*
* @author chenshun
* @email sunlightcs@gmail.com
* @date 2016年12月3日 上午12:07:22
*/
public enum ScheduleStatus {
/**
* 正常
*/
NORMAL(0),
/**
* 暂停
*/
PAUSE(1);
private int value;
ScheduleStatus(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
/**
* 云服务商
*/
public enum CloudService {
/**
* 腾讯云 COS
*/
QCLOUD(3, QcloudGroup.class);
private int value;
private Class<?> validatorGroupClass;
CloudService(int value, Class<?> validatorGroupClass) {
this.value = value;
this.validatorGroupClass = validatorGroupClass;
}
public int getValue() {
return value;
}
public Class<?> getValidatorGroupClass() {
return this.validatorGroupClass;
}
public static CloudService getByValue(Integer value) {
Optional<CloudService> first = Stream.of(CloudService.values()).filter(cs -> value.equals(cs.value)).findFirst();
if (!first.isPresent()) {
throw new IllegalArgumentException("非法的枚举值:" + value);
}
return first.get();
}
}
}

View File

@@ -0,0 +1,70 @@
package io.as.common.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.servlet.http.HttpServletRequest;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 采集请求中的自定义 header(X- 开头、sign 等),序列化为 JSON,供接口日志使用。
*/
public final class CustomHeaderCaptureUtils {
private static final ObjectMapper MAPPER = new ObjectMapper();
private CustomHeaderCaptureUtils() {
}
/**
* @param maxJsonLength 整段 JSON 最大长度,超出截断
*/
public static String captureAsJson(HttpServletRequest request, int maxJsonLength, int maxValueLength) {
if (request == null) {
return null;
}
try {
Map<String, String> headers = new LinkedHashMap<>();
Enumeration<String> names = request.getHeaderNames();
if (names == null) {
return null;
}
while (names.hasMoreElements()) {
String name = names.nextElement();
if (name == null) {
continue;
}
String n = name.toLowerCase();
if ("sign".equals(n) || n.startsWith("x-")) {
String value = request.getHeader(name);
if (value != null) {
headers.put(name, truncate(value, maxValueLength));
}
}
}
if (headers.isEmpty()) {
return null;
}
String json = MAPPER.writeValueAsString(headers);
return truncate(json, maxJsonLength);
} catch (Exception e) {
return null;
}
}
/** 与 api_jst_call_log.request_headers 一致的默认上限 */
public static String captureAsJson(HttpServletRequest request) {
return captureAsJson(request, 2000, 500);
}
private static String truncate(String s, int maxLen) {
if (s == null) {
return null;
}
if (s.length() <= maxLen) {
return s;
}
return s.substring(0, maxLen) + "...";
}
}

View File

@@ -0,0 +1,158 @@
package io.as.common.utils;
import org.apache.commons.lang.StringUtils;
import org.joda.time.DateTime;
import org.joda.time.LocalDate;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* 日期处理
*
* @author Mark sunlightcs@gmail.com
*/
public class DateUtils {
/** 时间格式(yyyy-MM-dd) */
public final static String DATE_PATTERN = "yyyy-MM-dd";
/** 时间格式(yyyy-MM-dd HH:mm:ss) */
public final static String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
/**
* 日期格式化 日期格式为:yyyy-MM-dd
* @param date 日期
* @return 返回yyyy-MM-dd格式日期
*/
public static String format(Date date) {
return format(date, DATE_PATTERN);
}
/**
* 日期格式化 日期格式为:yyyy-MM-dd
* @param date 日期
* @param pattern 格式,如:DateUtils.DATE_TIME_PATTERN
* @return 返回yyyy-MM-dd格式日期
*/
public static String format(Date date, String pattern) {
if(date != null){
SimpleDateFormat df = new SimpleDateFormat(pattern);
return df.format(date);
}
return null;
}
/**
* 字符串转换成日期
* @param strDate 日期字符串
* @param pattern 日期的格式,如:DateUtils.DATE_TIME_PATTERN
*/
public static Date stringToDate(String strDate, String pattern) {
if (StringUtils.isBlank(strDate)){
return null;
}
DateTimeFormatter fmt = DateTimeFormat.forPattern(pattern);
return fmt.parseLocalDateTime(strDate).toDate();
}
/**
* 根据周数,获取开始日期、结束日期
* @param week 周期 0本周,-1上周,-2上上周,1下周,2下下周
* @return 返回date[0]开始日期、date[1]结束日期
*/
public static Date[] getWeekStartAndEnd(int week) {
DateTime dateTime = new DateTime();
LocalDate date = new LocalDate(dateTime.plusWeeks(week));
date = date.dayOfWeek().withMinimumValue();
Date beginDate = date.toDate();
Date endDate = date.plusDays(6).toDate();
return new Date[]{beginDate, endDate};
}
/**
* 对日期的【秒】进行加/减
*
* @param date 日期
* @param seconds 秒数,负数为减
* @return 加/减几秒后的日期
*/
public static Date addDateSeconds(Date date, int seconds) {
DateTime dateTime = new DateTime(date);
return dateTime.plusSeconds(seconds).toDate();
}
/**
* 对日期的【分钟】进行加/减
*
* @param date 日期
* @param minutes 分钟数,负数为减
* @return 加/减几分钟后的日期
*/
public static Date addDateMinutes(Date date, int minutes) {
DateTime dateTime = new DateTime(date);
return dateTime.plusMinutes(minutes).toDate();
}
/**
* 对日期的【小时】进行加/减
*
* @param date 日期
* @param hours 小时数,负数为减
* @return 加/减几小时后的日期
*/
public static Date addDateHours(Date date, int hours) {
DateTime dateTime = new DateTime(date);
return dateTime.plusHours(hours).toDate();
}
/**
* 对日期的【天】进行加/减
*
* @param date 日期
* @param days 天数,负数为减
* @return 加/减几天后的日期
*/
public static Date addDateDays(Date date, int days) {
DateTime dateTime = new DateTime(date);
return dateTime.plusDays(days).toDate();
}
/**
* 对日期的【周】进行加/减
*
* @param date 日期
* @param weeks 周数,负数为减
* @return 加/减几周后的日期
*/
public static Date addDateWeeks(Date date, int weeks) {
DateTime dateTime = new DateTime(date);
return dateTime.plusWeeks(weeks).toDate();
}
/**
* 对日期的【月】进行加/减
*
* @param date 日期
* @param months 月数,负数为减
* @return 加/减几月后的日期
*/
public static Date addDateMonths(Date date, int months) {
DateTime dateTime = new DateTime(date);
return dateTime.plusMonths(months).toDate();
}
/**
* 对日期的【年】进行加/减
*
* @param date 日期
* @param years 年数,负数为减
* @return 加/减几年后的日期
*/
public static Date addDateYears(Date date, int years) {
DateTime dateTime = new DateTime(date);
return dateTime.plusYears(years).toDate();
}
}

View File

@@ -0,0 +1,24 @@
package io.as.common.utils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
public class HttpContextUtils {
public static HttpServletRequest getHttpServletRequest() {
return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
}
public static String getDomain(){
HttpServletRequest request = getHttpServletRequest();
StringBuffer url = request.getRequestURL();
return url.delete(url.length() - request.getRequestURI().length(), url.length()).toString();
}
public static String getOrigin(){
HttpServletRequest request = getHttpServletRequest();
return request.getHeader("Origin");
}
}

View File

@@ -0,0 +1,56 @@
package io.as.common.utils;
import com.alibaba.druid.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.http.HttpServletRequest;
/**
* IP地址
*
* @author Mark sunlightcs@gmail.com
*/
public class IPUtils {
private static Logger logger = LoggerFactory.getLogger(IPUtils.class);
/**
* 获取IP地址
*
* 使用Nginx等反向代理软件, 则不能通过request.getRemoteAddr()获取IP地址
* 如果使用了多级反向代理的话,X-Forwarded-For的值并不止一个,而是一串IP地址,X-Forwarded-For中第一个非unknown的有效IP字符串,则为真实IP地址
*/
public static String getIpAddr(HttpServletRequest request) {
String ip = null;
try {
ip = request.getHeader("x-forwarded-for");
if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (StringUtils.isEmpty(ip) || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
} catch (Exception e) {
logger.error("IPUtils ERROR ", e);
}
// //使用代理,则获取第一个IP地址
// if(StringUtils.isEmpty(ip) && ip.length() > 15) {
// if(ip.indexOf(",") > 0) {
// ip = ip.substring(0, ip.indexOf(","));
// }
// }
return ip;
}
}

View File

@@ -0,0 +1,151 @@
package io.as.common.utils;
import org.apache.commons.lang.StringUtils;
/**
* MAC 地址工具类
* 用于校验 MAC 地址的合法性
*/
public class MacAddressUtils {
/**
* 校验 MAC 地址是否合法(单播地址,可作为源地址)
*
* 核心规则:
* 1. 第一字节的最低位(I/G位)必须是 0 - 代表单播地址
* - 0:单播(Individual),可作为源地址
* - 1:组播(Group),只能作为目标地址,不能作为源地址
* 2. 不能是全0地址(00:00:00:00:00:00)
* 3. 不能是全F地址(FF:FF:FF:FF:FF:FF,广播地址)
*
* @param mac MAC 地址字符串(支持格式:AA:BB:CC:DD:EE:FF 或 AA-BB-CC-DD-EE-FF 或 AABBCCDDEEFF)
* @return true-合法的单播MAC地址,false-非法
*/
public static boolean isValidUnicastMac(String mac) {
if (StringUtils.isBlank(mac)) {
return true; // 空值不校验,由调用方决定
}
// 标准化 MAC 地址(去除分隔符,转为大写)
String normalizedMac = normalizeMac(mac);
if (normalizedMac == null || normalizedMac.length() != 12) {
return false; // 格式错误
}
// 检查是否全0
if ("000000000000".equals(normalizedMac)) {
return false; // 全0地址无效
}
// 检查是否全F(广播地址)
if ("FFFFFFFFFFFF".equalsIgnoreCase(normalizedMac)) {
return false; // 广播地址无效
}
// 获取第一字节
int firstByte;
try {
firstByte = Integer.parseInt(normalizedMac.substring(0, 2), 16);
} catch (NumberFormatException e) {
return false; // 解析错误
}
// 检查 I/G 位(最低位):必须为0(单播)
// 第一字节的二进制:xxxx xxx0
if ((firstByte & 0x01) != 0) {
return false; // I/G位为1,是组播地址,不能作为源地址
}
return true;
}
/**
* 获取 MAC 地址类型描述
*
* @param mac MAC 地址字符串
* @return 地址类型描述
*/
public static String getMacAddressType(String mac) {
if (StringUtils.isBlank(mac)) {
return "空地址";
}
String normalizedMac = normalizeMac(mac);
if (normalizedMac == null || normalizedMac.length() != 12) {
return "格式错误";
}
// 检查是否全0
if ("000000000000".equals(normalizedMac)) {
return "全0地址(无效)";
}
// 检查是否全F(广播地址)
if ("FFFFFFFFFFFF".equalsIgnoreCase(normalizedMac)) {
return "广播地址(无效)";
}
int firstByte;
try {
firstByte = Integer.parseInt(normalizedMac.substring(0, 2), 16);
} catch (NumberFormatException e) {
return "格式错误";
}
boolean isMulticast = (firstByte & 0x01) != 0;
boolean isLocalAdmin = (firstByte & 0x02) != 0;
if (isMulticast) {
return "组播地址(只能作为目标地址)";
} else if (isLocalAdmin) {
return "本地管理单播地址(LAA)";
} else {
return "全球唯一单播地址(UAA)";
}
}
/**
* 标准化 MAC 地址
* 去除分隔符(: - .),转为大写,保留12位十六进制字符
*
* @param mac 原始 MAC 地址
* @return 标准化后的 MAC 地址(12位大写十六进制字符串),如果格式错误返回 null
*/
public static String normalizeMac(String mac) {
if (StringUtils.isBlank(mac)) {
return null;
}
// 去除所有分隔符和空白
String cleaned = mac.replaceAll("[:.\\s-]", "").toUpperCase();
// 验证是否为有效的十六进制字符串
if (!cleaned.matches("^[0-9A-F]{12}$")) {
return null;
}
return cleaned;
}
/**
* 格式化 MAC 地址为冒号分隔格式
*
* @param mac 原始 MAC 地址
* @return AA:BB:CC:DD:EE:FF 格式的 MAC 地址
*/
public static String formatMac(String mac) {
String normalized = normalizeMac(mac);
if (normalized == null) {
return null;
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 12; i += 2) {
if (i > 0) {
sb.append(':');
}
sb.append(normalized.substring(i, i + 2));
}
return sb.toString();
}
}

View File

@@ -0,0 +1,18 @@
package io.as.common.utils;
import java.util.HashMap;
/**
* Map工具类
*
* @author Mark sunlightcs@gmail.com
*/
public class MapUtils extends HashMap<String, Object> {
@Override
public MapUtils put(String key, Object value) {
super.put(key, value);
return this;
}
}

View File

@@ -0,0 +1,102 @@
package io.as.common.utils;
import com.baomidou.mybatisplus.core.metadata.IPage;
import java.io.Serializable;
import java.util.List;
/**
* 分页工具类
*
* @author Mark sunlightcs@gmail.com
*/
public class PageUtils implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 总记录数
*/
private int totalCount;
/**
* 每页记录数
*/
private int pageSize;
/**
* 总页数
*/
private int totalPage;
/**
* 当前页数
*/
private int currPage;
/**
* 列表数据
*/
private List<?> list;
/**
* 分页
* @param list 列表数据
* @param totalCount 总记录数
* @param pageSize 每页记录数
* @param currPage 当前页数
*/
public PageUtils(List<?> list, int totalCount, int pageSize, int currPage) {
this.list = list;
this.totalCount = totalCount;
this.pageSize = pageSize;
this.currPage = currPage;
this.totalPage = (int)Math.ceil((double)totalCount/pageSize);
}
/**
* 分页
*/
public PageUtils(IPage<?> page) {
this.list = page.getRecords();
this.totalCount = (int)page.getTotal();
this.pageSize = (int)page.getSize();
this.currPage = (int)page.getCurrent();
this.totalPage = (int)page.getPages();
}
public int getTotalCount() {
return totalCount;
}
public void setTotalCount(int totalCount) {
this.totalCount = totalCount;
}
public int getPageSize() {
return pageSize;
}
public void setPageSize(int pageSize) {
this.pageSize = pageSize;
}
public int getTotalPage() {
return totalPage;
}
public void setTotalPage(int totalPage) {
this.totalPage = totalPage;
}
public int getCurrPage() {
return currPage;
}
public void setCurrPage(int currPage) {
this.currPage = currPage;
}
public List<?> getList() {
return list;
}
public void setList(List<?> list) {
this.list = list;
}
}

View File

@@ -0,0 +1,69 @@
package io.as.common.utils;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.as.common.xss.SQLFilter;
import org.apache.commons.lang.StringUtils;
import java.util.Map;
/**
* 查询参数
*
* @author Mark sunlightcs@gmail.com
*/
public class Query<T> {
public IPage<T> getPage(Map<String, Object> params) {
return this.getPage(params, null, false);
}
public IPage<T> getPage(Map<String, Object> params, String defaultOrderField, boolean isAsc) {
//分页参数
long curPage = 1;
long limit = 10;
if(params.get(Constant.PAGE) != null){
curPage = Long.parseLong((String)params.get(Constant.PAGE));
}
if(params.get(Constant.LIMIT) != null){
limit = Long.parseLong((String)params.get(Constant.LIMIT));
}
//分页对象
Page<T> page = new Page<>(curPage, limit);
//分页参数
params.put(Constant.PAGE, page);
//排序字段
//防止SQL注入(因为sidx、order是通过拼接SQL实现排序的,会有SQL注入风险)
String orderField = SQLFilter.sqlInject((String)params.get(Constant.ORDER_FIELD));
String order = (String)params.get(Constant.ORDER);
//前端字段排序
if(StringUtils.isNotEmpty(orderField) && StringUtils.isNotEmpty(order)){
if(Constant.ASC.equalsIgnoreCase(order)) {
return page.addOrder(OrderItem.asc(orderField));
}else {
return page.addOrder(OrderItem.desc(orderField));
}
}
//没有排序字段,则不排序
if(StringUtils.isBlank(defaultOrderField)){
return page;
}
//默认排序
if(isAsc) {
page.addOrder(OrderItem.asc(defaultOrderField));
}else {
page.addOrder(OrderItem.desc(defaultOrderField));
}
return page;
}
}

View File

@@ -0,0 +1,99 @@
package io.as.common.utils;
import org.apache.http.HttpStatus;
import java.util.HashMap;
import java.util.Map;
/**
* 返回数据
*
* @author Mark sunlightcs@gmail.com
*/
public class R extends HashMap<String, Object> {
private static final long serialVersionUID = 1L;
public R() {
put("code", 0);
put("msg", "success");
}
public static R error() {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "未知异常,请联系管理员");
}
public static R error(String msg) {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg);
}
public static R error(int code, String msg) {
R r = new R();
r.put("code", code);
r.put("msg", msg);
return r;
}
public static R ok(String msg) {
R r = new R();
r.put("msg", msg);
return r;
}
public static R ok(Map<String, Object> map) {
R r = new R();
r.putAll(map);
return r;
}
public static R ok() {
return new R();
}
/**
* App 等统一响应:成功时 {@code { "code": 200, "msg": "", "data": {...} }}
*/
public static R dataOk() {
return dataOk(new HashMap<String, Object>());
}
/**
* App 等统一响应:成功时 payload 放在 {@code data} 内。
*/
public static R dataOk(Map<String, Object> data) {
R r = new R();
r.clear();
r.put("code", 200);
r.put("msg", "");
r.put("data", data != null ? data : new HashMap<String, Object>());
return r;
}
/**
* 统一响应(code 为 0):成功时 {@code { "code": 0, "msg": "", "data": {...} }}
*/
public static R dataSuccess(Map<String, Object> data) {
R r = new R();
r.clear();
r.put("code", 0);
r.put("msg", "");
r.put("data", data != null ? data : new HashMap<String, Object>());
return r;
}
/**
* App 等统一响应:失败时 {@code { "code": code, "msg": msg, "data": {} }}
*/
public static R dataError(int code, String msg) {
R r = new R();
r.clear();
r.put("code", code);
r.put("msg", msg != null ? msg : "");
r.put("data", new HashMap<String, Object>());
return r;
}
public R put(String key, Object value) {
super.put(key, value);
return this;
}
}

View File

@@ -0,0 +1,13 @@
package io.as.common.utils;
/**
* Redis所有Keys
*
* @author Mark sunlightcs@gmail.com
*/
public class RedisKeys {
public static String getSysConfigKey(String key){
return "sys:config:" + key;
}
}

View File

@@ -0,0 +1,112 @@
package io.as.common.utils;
import com.google.gson.Gson;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.*;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* Redis工具类
*
* @author Mark sunlightcs@gmail.com
*/
@Component
public class RedisUtils {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ValueOperations<String, String> valueOperations;
@Autowired
private HashOperations<String, String, Object> hashOperations;
@Autowired
private ListOperations<String, Object> listOperations;
@Autowired
private SetOperations<String, Object> setOperations;
@Autowired
private ZSetOperations<String, Object> zSetOperations;
/** 默认过期时长,单位:秒 */
public final static long DEFAULT_EXPIRE = 60 * 60 * 24;
/** 不设置过期时长 */
public final static long NOT_EXPIRE = -1;
private final static Gson gson = new Gson();
public void set(String key, Object value, long expire){
valueOperations.set(key, toJson(value));
if(expire != NOT_EXPIRE){
redisTemplate.expire(key, expire, TimeUnit.SECONDS);
}
}
public void set(String key, Object value){
set(key, value, DEFAULT_EXPIRE);
}
public <T> T get(String key, Class<T> clazz, long expire) {
String value = valueOperations.get(key);
if(expire != NOT_EXPIRE){
redisTemplate.expire(key, expire, TimeUnit.SECONDS);
}
return value == null ? null : fromJson(value, clazz);
}
public <T> T get(String key, Class<T> clazz) {
return get(key, clazz, NOT_EXPIRE);
}
public String get(String key, long expire) {
String value = valueOperations.get(key);
if(expire != NOT_EXPIRE){
redisTemplate.expire(key, expire, TimeUnit.SECONDS);
}
return value;
}
public String get(String key) {
return get(key, NOT_EXPIRE);
}
/** SETNX:仅当key不存在时设置值,同时设置过期时间(秒),返回是否设置成功 */
public boolean setIfAbsent(String key, String value, long expire) {
Boolean result = valueOperations.setIfAbsent(key, value, expire, TimeUnit.SECONDS);
return result != null && result;
}
public void delete(String key) {
redisTemplate.delete(key);
}
/**
* SETNX:仅当 key 不存在时设置值,并设置过期时间
*
* @param key Redis key
* @param value 值
* @param expire 过期时间(秒)
* @return true=设置成功(key 原先不存在),false=key 已存在
*/
public boolean setIfAbsent(String key, Object value, long expire) {
if (expire == NOT_EXPIRE) {
return valueOperations.setIfAbsent(key, toJson(value));
}
return valueOperations.setIfAbsent(key, toJson(value), expire, TimeUnit.SECONDS);
}
/**
* Object转成JSON数据
*/
private String toJson(Object object){
if(object instanceof Integer || object instanceof Long || object instanceof Float ||
object instanceof Double || object instanceof Boolean || object instanceof String){
return String.valueOf(object);
}
return gson.toJson(object);
}
/**
* JSON数据,转成Object
*/
private <T> T fromJson(String json, Class<T> clazz){
return gson.fromJson(json, clazz);
}
}

View File

@@ -0,0 +1,160 @@
package io.as.common.utils;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.google.gson.Gson;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import java.security.*;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.HashMap;
import java.util.UUID;
/**
* 纯 JDK 实现的 RSA 工具类
* 不依赖任何第三方库
*/
public class RsaUtil {
// 加密算法
private static final String CIPHER_ALGORITHM = "RSA/ECB/PKCS1Padding";
// 密钥算法
private static final String KEY_ALGORITHM = "RSA";
/**
* 免登录接口加密demo,并发送 POST 请求
* @param args
*/
public static void main(String[] args) {
// 生成 timestamp 和 nonce,需与请求 URL 参数保持一致
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = UUID.randomUUID().toString().replace("-", "");
HashMap<String, String> signMap = new HashMap<>();
signMap.put("accesser", "ASJDTEST");
signMap.put("businessLine", "4100450011967708592750001");
signMap.put("uid", "device_77");
signMap.put("mac", "device_77");
signMap.put("nonce", nonce);
signMap.put("timestamp", timestamp);
signMap.put("serviceComboCode", "1100121010200492753300170");
Gson gson = new Gson();
String plainData = gson.toJson(signMap);
System.out.println("待加密字符串为:" + plainData);
String publicKeyStr = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnemyIjwf1S5PsqgPhwiPGcF2Efa4UvFiFpg34+d2ZHQ6mkTTaussXCWQQRZGUfGbvkhIJOspGxmxtaMYgNxMQv9BT5B6xcnMILqYzG40hc2N3EiLm4hDYP12f8pshikh5aNxolbgkdA+Y2vrMf2KNucArMh8gT1mUuQRyUMiefy+N/kk2nK2p6SSX0628/BtczNgj13MaMHMlr8krNTlzB/22M8WMSk10nFS4ULp1KOPia8VxqzMIFKeREPIHIUnarz+llFIj6zKGg0ocqS6KEml7jv1yCgBpmyA2kAGEgnuTreO/5h5DqXByKLBEW8bYDiMUb7H/58IRerrITXfVQIDAQAB";
String encryptData = encrypt(publicKeyStr, plainData);
System.out.println("加密后的encryptData为:" + encryptData);
String url = "https://external.future.jetsentv.com/ums_api/api/v1/third/login"
+ "?timestamp=" + timestamp
+ "&nonce=" + nonce
+ "&serviceComboCode=1100121010200492753300170";
HttpResponse response = HttpRequest.post(url)
.header("tenant", "huashitv")
.header("encryptData", encryptData)
.header("Cookie", "locale=en-us")
.body("") // 空请求体,与 curl --data '' 一致
.timeout(10000)
.execute();
System.out.println("响应状态码:" + response.getStatus());
System.out.println("响应内容:" + response.body());
}
/**
* 使用公钥加密字符串
*
* @param key 公钥
* @param plainText 明文字符串
* @return 加密后的Base64编码字符串
*/
public static String encrypt(PublicKey key, String plainText) {
try {
Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, key);
// 将字符串转换为字节数组
byte[] plainTextBytes = plainText.getBytes("UTF-8");
// 分段加密(RSA有加密数据长度限制)
int inputLen = plainTextBytes.length;
int maxEncryptBlock = 245; // 2048位密钥的最大加密块大小
byte[] encryptedData = new byte[0];
int offset = 0;
while (inputLen - offset > 0) {
if (inputLen - offset > maxEncryptBlock) {
byte[] encryptedBlock = cipher.doFinal(plainTextBytes, offset, maxEncryptBlock);
encryptedData = concatenateByteArrays(encryptedData, encryptedBlock);
offset += maxEncryptBlock;
} else {
byte[] encryptedBlock = cipher.doFinal(plainTextBytes, offset, inputLen - offset);
encryptedData = concatenateByteArrays(encryptedData, encryptedBlock);
offset = inputLen;
}
}
// 返回Base64编码的加密数据
return Base64.getEncoder().encodeToString(encryptedData);
} catch (NoSuchAlgorithmException | NoSuchPaddingException
| InvalidKeyException | IllegalBlockSizeException
| BadPaddingException | java.io.UnsupportedEncodingException e) {
throw new RuntimeException("加密失败", e);
}
}
/**
* 使用Base64编码的公钥字符串加密
*
* @param publicKeyStr Base64编码的公钥字符串
* @param plainText 明文字符串
* @return 加密后的Base64编码字符串
*/
public static String encrypt(String publicKeyStr, String plainText) {
try {
PublicKey publicKey = getPublicKeyFromString(publicKeyStr);
return encrypt(publicKey, plainText);
} catch (Exception e) {
throw new RuntimeException("加密失败", e);
}
}
/**
* 从Base64编码的字符串获取公钥
*
* @param publicKeyStr Base64编码的公钥字符串
* @return PublicKey对象
*/
public static PublicKey getPublicKeyFromString(String publicKeyStr) {
try {
byte[] keyBytes = Base64.getDecoder().decode(publicKeyStr);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
return keyFactory.generatePublic(keySpec);
} catch (Exception e) {
throw new RuntimeException("获取公钥失败", e);
}
}
/**
* 合并字节数组
*
* @param a 第一个字节数组
* @param b 第二个字节数组
* @return 合并后的字节数组
*/
private static byte[] concatenateByteArrays(byte[] a, byte[] b) {
byte[] result = new byte[a.length + b.length];
System.arraycopy(a, 0, result, 0, a.length);
System.arraycopy(b, 0, result, a.length, b.length);
return result;
}
}

View File

@@ -0,0 +1,53 @@
package io.as.common.utils;
import io.as.common.exception.ASException;
import io.as.modules.sys.entity.SysUserEntity;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.session.Session;
import org.apache.shiro.subject.Subject;
/**
* Shiro工具类
*
* @author Mark sunlightcs@gmail.com
*/
public class ShiroUtils {
public static Session getSession() {
return SecurityUtils.getSubject().getSession();
}
public static Subject getSubject() {
return SecurityUtils.getSubject();
}
public static SysUserEntity getUserEntity() {
return (SysUserEntity)SecurityUtils.getSubject().getPrincipal();
}
public static Long getUserId() {
return getUserEntity().getId();
}
public static void setSessionAttribute(Object key, Object value) {
getSession().setAttribute(key, value);
}
public static Object getSessionAttribute(Object key) {
return getSession().getAttribute(key);
}
public static boolean isLogin() {
return SecurityUtils.getSubject().getPrincipal() != null;
}
public static String getKaptcha(String key) {
Object kaptcha = getSessionAttribute(key);
if(kaptcha == null){
throw new ASException("验证码已失效");
}
getSession().removeAttribute(key);
return kaptcha.toString();
}
}

View File

@@ -0,0 +1,47 @@
package io.as.common.utils;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
/**
* Spring Context 工具类
*
* @author Mark sunlightcs@gmail.com
*/
@Component
public class SpringContextUtils implements ApplicationContextAware {
public static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext)
throws BeansException {
SpringContextUtils.applicationContext = applicationContext;
}
public static Object getBean(String name) {
return applicationContext.getBean(name);
}
public static <T> T getBean(String name, Class<T> requiredType) {
return applicationContext.getBean(name, requiredType);
}
public static <T> T getBean(Class<T> requiredType) {
return applicationContext.getBean(requiredType);
}
public static boolean containsBean(String name) {
return applicationContext.containsBean(name);
}
public static boolean isSingleton(String name) {
return applicationContext.isSingleton(name);
}
public static Class<? extends Object> getType(String name) {
return applicationContext.getType(name);
}
}

View File

@@ -0,0 +1,24 @@
package io.as.common.validator;
import io.as.common.exception.ASException;
import org.apache.commons.lang.StringUtils;
/**
* 数据校验
*
* @author Mark sunlightcs@gmail.com
*/
public abstract class Assert {
public static void isBlank(String str, String message) {
if (StringUtils.isBlank(str)) {
throw new ASException(message);
}
}
public static void isNull(Object object, String message) {
if (object == null) {
throw new ASException(message);
}
}
}

View File

@@ -0,0 +1,46 @@
package io.as.common.validator;
import io.as.common.exception.ASException;
import io.as.common.utils.Constant;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import java.util.Set;
/**
* hibernate-validator校验工具类
*
* 参考文档:http://docs.jboss.org/hibernate/validator/5.4/reference/en-US/html_single/
*
* @author Mark sunlightcs@gmail.com
*/
public class ValidatorUtils {
private static Validator validator;
static {
validator = Validation.buildDefaultValidatorFactory().getValidator();
}
/**
* 校验对象
* @param object 待校验对象
* @param groups 待校验的组
* @throws ASException 校验不通过,则报ASException异常
*/
public static void validateEntity(Object object, Class<?>... groups)
throws ASException {
Set<ConstraintViolation<Object>> constraintViolations = validator.validate(object, groups);
if (!constraintViolations.isEmpty()) {
StringBuilder msg = new StringBuilder();
for (ConstraintViolation<Object> constraint : constraintViolations) {
msg.append(constraint.getMessage()).append("<br>");
}
throw new ASException(msg.toString());
}
}
public static void validateEntity(Object object, Constant.CloudService type) {
validateEntity(object, type.getValidatorGroupClass());
}
}

View File

@@ -0,0 +1,9 @@
package io.as.common.validator.group;
/**
* 新增数据 Group
*
* @author Mark sunlightcs@gmail.com
*/
public interface AddGroup {
}

View File

@@ -0,0 +1,13 @@
package io.as.common.validator.group;
import javax.validation.GroupSequence;
/**
* 定义校验顺序,如果AddGroup组失败,则UpdateGroup组不会再校验
*
* @author Mark sunlightcs@gmail.com
*/
@GroupSequence({AddGroup.class, UpdateGroup.class})
public interface Group {
}

View File

@@ -0,0 +1,9 @@
package io.as.common.validator.group;
/**
* 腾讯云
*
* @author Mark sunlightcs@gmail.com
*/
public interface QcloudGroup {
}

View File

@@ -0,0 +1,11 @@
package io.as.common.validator.group;
/**
* 更新数据 Group
*
* @author Mark sunlightcs@gmail.com
*/
public interface UpdateGroup {
}

View File

@@ -0,0 +1,530 @@
package io.as.common.xss;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
*
* HTML filtering utility for protecting against XSS (Cross Site Scripting).
*
* This code is licensed LGPLv3
*
* This code is a Java port of the original work in PHP by Cal Hendersen.
* http://code.iamcal.com/php/lib_filter/
*
* The trickiest part of the translation was handling the differences in regex handling
* between PHP and Java. These resources were helpful in the process:
*
* http://java.sun.com/j2se/1.4.2/docs/api/java/util/regex/Pattern.html
* http://us2.php.net/manual/en/reference.pcre.pattern.modifiers.php
* http://www.regular-expressions.info/modifiers.html
*
* A note on naming conventions: instance variables are prefixed with a "v"; global
* constants are in all caps.
*
* Sample use:
* String input = ...
* String clean = new HTMLFilter().filter( input );
*
* The class is not thread safe. Create a new instance if in doubt.
*
* If you find bugs or have suggestions on improvement (especially regarding
* performance), please contact us. The latest version of this
* source, and our contact details, can be found at http://xss-html-filter.sf.net
*
* @author Joseph O'Connell
* @author Cal Hendersen
* @author Michael Semb Wever
*/
public final class HTMLFilter {
/** regex flag union representing /si modifiers in php **/
private static final int REGEX_FLAGS_SI = Pattern.CASE_INSENSITIVE | Pattern.DOTALL;
private static final Pattern P_COMMENTS = Pattern.compile("<!--(.*?)-->", Pattern.DOTALL);
private static final Pattern P_COMMENT = Pattern.compile("^!--(.*)--$", REGEX_FLAGS_SI);
private static final Pattern P_TAGS = Pattern.compile("<(.*?)>", Pattern.DOTALL);
private static final Pattern P_END_TAG = Pattern.compile("^/([a-z0-9]+)", REGEX_FLAGS_SI);
private static final Pattern P_START_TAG = Pattern.compile("^([a-z0-9]+)(.*?)(/?)$", REGEX_FLAGS_SI);
private static final Pattern P_QUOTED_ATTRIBUTES = Pattern.compile("([a-z0-9]+)=([\"'])(.*?)\\2", REGEX_FLAGS_SI);
private static final Pattern P_UNQUOTED_ATTRIBUTES = Pattern.compile("([a-z0-9]+)(=)([^\"\\s']+)", REGEX_FLAGS_SI);
private static final Pattern P_PROTOCOL = Pattern.compile("^([^:]+):", REGEX_FLAGS_SI);
private static final Pattern P_ENTITY = Pattern.compile("&#(\\d+);?");
private static final Pattern P_ENTITY_UNICODE = Pattern.compile("&#x([0-9a-f]+);?");
private static final Pattern P_ENCODE = Pattern.compile("%([0-9a-f]{2});?");
private static final Pattern P_VALID_ENTITIES = Pattern.compile("&([^&;]*)(?=(;|&|$))");
private static final Pattern P_VALID_QUOTES = Pattern.compile("(>|^)([^<]+?)(<|$)", Pattern.DOTALL);
private static final Pattern P_END_ARROW = Pattern.compile("^>");
private static final Pattern P_BODY_TO_END = Pattern.compile("<([^>]*?)(?=<|$)");
private static final Pattern P_XML_CONTENT = Pattern.compile("(^|>)([^<]*?)(?=>)");
private static final Pattern P_STRAY_LEFT_ARROW = Pattern.compile("<([^>]*?)(?=<|$)");
private static final Pattern P_STRAY_RIGHT_ARROW = Pattern.compile("(^|>)([^<]*?)(?=>)");
private static final Pattern P_AMP = Pattern.compile("&");
private static final Pattern P_QUOTE = Pattern.compile("<");
private static final Pattern P_LEFT_ARROW = Pattern.compile("<");
private static final Pattern P_RIGHT_ARROW = Pattern.compile(">");
private static final Pattern P_BOTH_ARROWS = Pattern.compile("<>");
// @xxx could grow large... maybe use sesat's ReferenceMap
private static final ConcurrentMap<String,Pattern> P_REMOVE_PAIR_BLANKS = new ConcurrentHashMap<String, Pattern>();
private static final ConcurrentMap<String,Pattern> P_REMOVE_SELF_BLANKS = new ConcurrentHashMap<String, Pattern>();
/** set of allowed html elements, along with allowed attributes for each element **/
private final Map<String, List<String>> vAllowed;
/** counts of open tags for each (allowable) html element **/
private final Map<String, Integer> vTagCounts = new HashMap<String, Integer>();
/** html elements which must always be self-closing (e.g. "<img />") **/
private final String[] vSelfClosingTags;
/** html elements which must always have separate opening and closing tags (e.g. "<b></b>") **/
private final String[] vNeedClosingTags;
/** set of disallowed html elements **/
private final String[] vDisallowed;
/** attributes which should be checked for valid protocols **/
private final String[] vProtocolAtts;
/** allowed protocols **/
private final String[] vAllowedProtocols;
/** tags which should be removed if they contain no content (e.g. "<b></b>" or "<b />") **/
private final String[] vRemoveBlanks;
/** entities allowed within html markup **/
private final String[] vAllowedEntities;
/** flag determining whether comments are allowed in input String. */
private final boolean stripComment;
private final boolean encodeQuotes;
private boolean vDebug = false;
/**
* flag determining whether to try to make tags when presented with "unbalanced"
* angle brackets (e.g. "<b text </b>" becomes "<b> text </b>"). If set to false,
* unbalanced angle brackets will be html escaped.
*/
private final boolean alwaysMakeTags;
/** Default constructor.
*
*/
public HTMLFilter() {
vAllowed = new HashMap<>();
final ArrayList<String> a_atts = new ArrayList<String>();
a_atts.add("href");
a_atts.add("target");
vAllowed.put("a", a_atts);
final ArrayList<String> img_atts = new ArrayList<String>();
img_atts.add("src");
img_atts.add("width");
img_atts.add("height");
img_atts.add("alt");
vAllowed.put("img", img_atts);
final ArrayList<String> no_atts = new ArrayList<String>();
vAllowed.put("b", no_atts);
vAllowed.put("strong", no_atts);
vAllowed.put("i", no_atts);
vAllowed.put("em", no_atts);
vSelfClosingTags = new String[]{"img"};
vNeedClosingTags = new String[]{"a", "b", "strong", "i", "em"};
vDisallowed = new String[]{};
vAllowedProtocols = new String[]{"http", "mailto", "https"}; // no ftp.
vProtocolAtts = new String[]{"src", "href"};
vRemoveBlanks = new String[]{"a", "b", "strong", "i", "em"};
vAllowedEntities = new String[]{"amp", "gt", "lt", "quot"};
stripComment = true;
encodeQuotes = true;
alwaysMakeTags = true;
}
/** Set debug flag to true. Otherwise use default settings. See the default constructor.
*
* @param debug turn debug on with a true argument
*/
public HTMLFilter(final boolean debug) {
this();
vDebug = debug;
}
/** Map-parameter configurable constructor.
*
* @param conf map containing configuration. keys match field names.
*/
public HTMLFilter(final Map<String,Object> conf) {
assert conf.containsKey("vAllowed") : "configuration requires vAllowed";
assert conf.containsKey("vSelfClosingTags") : "configuration requires vSelfClosingTags";
assert conf.containsKey("vNeedClosingTags") : "configuration requires vNeedClosingTags";
assert conf.containsKey("vDisallowed") : "configuration requires vDisallowed";
assert conf.containsKey("vAllowedProtocols") : "configuration requires vAllowedProtocols";
assert conf.containsKey("vProtocolAtts") : "configuration requires vProtocolAtts";
assert conf.containsKey("vRemoveBlanks") : "configuration requires vRemoveBlanks";
assert conf.containsKey("vAllowedEntities") : "configuration requires vAllowedEntities";
vAllowed = Collections.unmodifiableMap((HashMap<String, List<String>>) conf.get("vAllowed"));
vSelfClosingTags = (String[]) conf.get("vSelfClosingTags");
vNeedClosingTags = (String[]) conf.get("vNeedClosingTags");
vDisallowed = (String[]) conf.get("vDisallowed");
vAllowedProtocols = (String[]) conf.get("vAllowedProtocols");
vProtocolAtts = (String[]) conf.get("vProtocolAtts");
vRemoveBlanks = (String[]) conf.get("vRemoveBlanks");
vAllowedEntities = (String[]) conf.get("vAllowedEntities");
stripComment = conf.containsKey("stripComment") ? (Boolean) conf.get("stripComment") : true;
encodeQuotes = conf.containsKey("encodeQuotes") ? (Boolean) conf.get("encodeQuotes") : true;
alwaysMakeTags = conf.containsKey("alwaysMakeTags") ? (Boolean) conf.get("alwaysMakeTags") : true;
}
private void reset() {
vTagCounts.clear();
}
private void debug(final String msg) {
if (vDebug) {
Logger.getAnonymousLogger().info(msg);
}
}
//---------------------------------------------------------------
// my versions of some PHP library functions
public static String chr(final int decimal) {
return String.valueOf((char) decimal);
}
public static String htmlSpecialChars(final String s) {
String result = s;
result = regexReplace(P_AMP, "&amp;", result);
result = regexReplace(P_QUOTE, "&quot;", result);
result = regexReplace(P_LEFT_ARROW, "&lt;", result);
result = regexReplace(P_RIGHT_ARROW, "&gt;", result);
return result;
}
//---------------------------------------------------------------
/**
* given a user submitted input String, filter out any invalid or restricted
* html.
*
* @param input text (i.e. submitted by a user) than may contain html
* @return "clean" version of input, with only valid, whitelisted html elements allowed
*/
public String filter(final String input) {
reset();
String s = input;
debug("************************************************");
debug(" INPUT: " + input);
s = escapeComments(s);
debug(" escapeComments: " + s);
s = balanceHTML(s);
debug(" balanceHTML: " + s);
s = checkTags(s);
debug(" checkTags: " + s);
s = processRemoveBlanks(s);
debug("processRemoveBlanks: " + s);
s = validateEntities(s);
debug(" validateEntites: " + s);
debug("************************************************\n\n");
return s;
}
public boolean isAlwaysMakeTags(){
return alwaysMakeTags;
}
public boolean isStripComments(){
return stripComment;
}
private String escapeComments(final String s) {
final Matcher m = P_COMMENTS.matcher(s);
final StringBuffer buf = new StringBuffer();
if (m.find()) {
final String match = m.group(1); //(.*?)
m.appendReplacement(buf, Matcher.quoteReplacement("<!--" + htmlSpecialChars(match) + "-->"));
}
m.appendTail(buf);
return buf.toString();
}
private String balanceHTML(String s) {
if (alwaysMakeTags) {
//
// try and form html
//
s = regexReplace(P_END_ARROW, "", s);
s = regexReplace(P_BODY_TO_END, "<$1>", s);
s = regexReplace(P_XML_CONTENT, "$1<$2", s);
} else {
//
// escape stray brackets
//
s = regexReplace(P_STRAY_LEFT_ARROW, "&lt;$1", s);
s = regexReplace(P_STRAY_RIGHT_ARROW, "$1$2&gt;<", s);
//
// the last regexp causes '<>' entities to appear
// (we need to do a lookahead assertion so that the last bracket can
// be used in the next pass of the regexp)
//
s = regexReplace(P_BOTH_ARROWS, "", s);
}
return s;
}
private String checkTags(String s) {
Matcher m = P_TAGS.matcher(s);
final StringBuffer buf = new StringBuffer();
while (m.find()) {
String replaceStr = m.group(1);
replaceStr = processTag(replaceStr);
m.appendReplacement(buf, Matcher.quoteReplacement(replaceStr));
}
m.appendTail(buf);
s = buf.toString();
// these get tallied in processTag
// (remember to reset before subsequent calls to filter method)
for (String key : vTagCounts.keySet()) {
for (int ii = 0; ii < vTagCounts.get(key); ii++) {
s += "</" + key + ">";
}
}
return s;
}
private String processRemoveBlanks(final String s) {
String result = s;
for (String tag : vRemoveBlanks) {
if(!P_REMOVE_PAIR_BLANKS.containsKey(tag)){
P_REMOVE_PAIR_BLANKS.putIfAbsent(tag, Pattern.compile("<" + tag + "(\\s[^>]*)?></" + tag + ">"));
}
result = regexReplace(P_REMOVE_PAIR_BLANKS.get(tag), "", result);
if(!P_REMOVE_SELF_BLANKS.containsKey(tag)){
P_REMOVE_SELF_BLANKS.putIfAbsent(tag, Pattern.compile("<" + tag + "(\\s[^>]*)?/>"));
}
result = regexReplace(P_REMOVE_SELF_BLANKS.get(tag), "", result);
}
return result;
}
private static String regexReplace(final Pattern regex_pattern, final String replacement, final String s) {
Matcher m = regex_pattern.matcher(s);
return m.replaceAll(replacement);
}
private String processTag(final String s) {
// ending tags
Matcher m = P_END_TAG.matcher(s);
if (m.find()) {
final String name = m.group(1).toLowerCase();
if (allowed(name)) {
if (!inArray(name, vSelfClosingTags)) {
if (vTagCounts.containsKey(name)) {
vTagCounts.put(name, vTagCounts.get(name) - 1);
return "</" + name + ">";
}
}
}
}
// starting tags
m = P_START_TAG.matcher(s);
if (m.find()) {
final String name = m.group(1).toLowerCase();
final String body = m.group(2);
String ending = m.group(3);
//debug( "in a starting tag, name='" + name + "'; body='" + body + "'; ending='" + ending + "'" );
if (allowed(name)) {
String params = "";
final Matcher m2 = P_QUOTED_ATTRIBUTES.matcher(body);
final Matcher m3 = P_UNQUOTED_ATTRIBUTES.matcher(body);
final List<String> paramNames = new ArrayList<String>();
final List<String> paramValues = new ArrayList<String>();
while (m2.find()) {
paramNames.add(m2.group(1)); //([a-z0-9]+)
paramValues.add(m2.group(3)); //(.*?)
}
while (m3.find()) {
paramNames.add(m3.group(1)); //([a-z0-9]+)
paramValues.add(m3.group(3)); //([^\"\\s']+)
}
String paramName, paramValue;
for (int ii = 0; ii < paramNames.size(); ii++) {
paramName = paramNames.get(ii).toLowerCase();
paramValue = paramValues.get(ii);
// debug( "paramName='" + paramName + "'" );
// debug( "paramValue='" + paramValue + "'" );
// debug( "allowed? " + vAllowed.get( name ).contains( paramName ) );
if (allowedAttribute(name, paramName)) {
if (inArray(paramName, vProtocolAtts)) {
paramValue = processParamProtocol(paramValue);
}
params += " " + paramName + "=\"" + paramValue + "\"";
}
}
if (inArray(name, vSelfClosingTags)) {
ending = " /";
}
if (inArray(name, vNeedClosingTags)) {
ending = "";
}
if (ending == null || ending.length() < 1) {
if (vTagCounts.containsKey(name)) {
vTagCounts.put(name, vTagCounts.get(name) + 1);
} else {
vTagCounts.put(name, 1);
}
} else {
ending = " /";
}
return "<" + name + params + ending + ">";
} else {
return "";
}
}
// comments
m = P_COMMENT.matcher(s);
if (!stripComment && m.find()) {
return "<" + m.group() + ">";
}
return "";
}
private String processParamProtocol(String s) {
s = decodeEntities(s);
final Matcher m = P_PROTOCOL.matcher(s);
if (m.find()) {
final String protocol = m.group(1);
if (!inArray(protocol, vAllowedProtocols)) {
// bad protocol, turn into local anchor link instead
s = "#" + s.substring(protocol.length() + 1, s.length());
if (s.startsWith("#//")) {
s = "#" + s.substring(3, s.length());
}
}
}
return s;
}
private String decodeEntities(String s) {
StringBuffer buf = new StringBuffer();
Matcher m = P_ENTITY.matcher(s);
while (m.find()) {
final String match = m.group(1);
final int decimal = Integer.decode(match).intValue();
m.appendReplacement(buf, Matcher.quoteReplacement(chr(decimal)));
}
m.appendTail(buf);
s = buf.toString();
buf = new StringBuffer();
m = P_ENTITY_UNICODE.matcher(s);
while (m.find()) {
final String match = m.group(1);
final int decimal = Integer.valueOf(match, 16).intValue();
m.appendReplacement(buf, Matcher.quoteReplacement(chr(decimal)));
}
m.appendTail(buf);
s = buf.toString();
buf = new StringBuffer();
m = P_ENCODE.matcher(s);
while (m.find()) {
final String match = m.group(1);
final int decimal = Integer.valueOf(match, 16).intValue();
m.appendReplacement(buf, Matcher.quoteReplacement(chr(decimal)));
}
m.appendTail(buf);
s = buf.toString();
s = validateEntities(s);
return s;
}
private String validateEntities(final String s) {
StringBuffer buf = new StringBuffer();
// validate entities throughout the string
Matcher m = P_VALID_ENTITIES.matcher(s);
while (m.find()) {
final String one = m.group(1); //([^&;]*)
final String two = m.group(2); //(?=(;|&|$))
m.appendReplacement(buf, Matcher.quoteReplacement(checkEntity(one, two)));
}
m.appendTail(buf);
return encodeQuotes(buf.toString());
}
private String encodeQuotes(final String s){
if(encodeQuotes){
StringBuffer buf = new StringBuffer();
Matcher m = P_VALID_QUOTES.matcher(s);
while (m.find()) {
final String one = m.group(1); //(>|^)
final String two = m.group(2); //([^<]+?)
final String three = m.group(3); //(<|$)
m.appendReplacement(buf, Matcher.quoteReplacement(one + regexReplace(P_QUOTE, "&quot;", two) + three));
}
m.appendTail(buf);
return buf.toString();
}else{
return s;
}
}
private String checkEntity(final String preamble, final String term) {
return ";".equals(term) && isValidEntity(preamble)
? '&' + preamble
: "&amp;" + preamble;
}
private boolean isValidEntity(final String entity) {
return inArray(entity, vAllowedEntities);
}
private static boolean inArray(final String s, final String[] array) {
for (String item : array) {
if (item != null && item.equals(s)) {
return true;
}
}
return false;
}
private boolean allowed(final String name) {
return (vAllowed.isEmpty() || vAllowed.containsKey(name)) && !inArray(name, vDisallowed);
}
private boolean allowedAttribute(final String name, final String paramName) {
return allowed(name) && (vAllowed.isEmpty() || vAllowed.get(name).contains(paramName));
}
}

View File

@@ -0,0 +1,42 @@
package io.as.common.xss;
import io.as.common.exception.ASException;
import org.apache.commons.lang.StringUtils;
/**
* SQL过滤
*
* @author Mark sunlightcs@gmail.com
*/
public class SQLFilter {
/**
* SQL注入过滤
* @param str 待验证的字符串
*/
public static String sqlInject(String str){
if(StringUtils.isBlank(str)){
return null;
}
//去掉'|"|;|\字符
str = StringUtils.replace(str, "'", "");
str = StringUtils.replace(str, "\"", "");
str = StringUtils.replace(str, ";", "");
str = StringUtils.replace(str, "\\", "");
//转换成小写
str = str.toLowerCase();
//非法字符
String[] keywords = {"master", "truncate", "insert", "select", "delete", "update", "declare", "alter", "drop"};
//判断是否包含非法字符
for(String keyword : keywords){
if(str.indexOf(keyword) != -1){
throw new ASException("包含非法字符");
}
}
return str;
}
}

View File

@@ -0,0 +1,37 @@
package io.as.common.xss;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* XSS过滤
*
* @author Mark sunlightcs@gmail.com
*/
public class XssFilter implements Filter {
@Override
public void init(FilterConfig config) throws ServletException {
}
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// /api 需原样读 body 入 sys_api_log(ContentCachingRequestWrapper);Xss 对 JSON 会先读满内层流再换流,易导致下游 body 缓存为空,故 API 不走 XSS 包装
String path = httpRequest.getRequestURI();
String ctx = httpRequest.getContextPath() == null ? "" : httpRequest.getContextPath();
String apiBase = ctx + "/api";
if (path != null && (path.equals(apiBase) || path.startsWith(apiBase + "/"))) {
chain.doFilter(request, response);
return;
}
XssHttpServletRequestWrapper xssRequest = new XssHttpServletRequestWrapper(httpRequest);
chain.doFilter(xssRequest, response);
}
@Override
public void destroy() {
}
}

View File

@@ -0,0 +1,139 @@
package io.as.common.xss;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* XSS过滤处理
*
* @author Mark sunlightcs@gmail.com
*/
public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {
//没被包装过的HttpServletRequest(特殊场景,需要自己过滤)
HttpServletRequest orgRequest;
//html过滤
private final static HTMLFilter htmlFilter = new HTMLFilter();
public XssHttpServletRequestWrapper(HttpServletRequest request) {
super(request);
orgRequest = request;
}
@Override
public ServletInputStream getInputStream() throws IOException {
//非json类型,直接返回
if(!MediaType.APPLICATION_JSON_VALUE.equalsIgnoreCase(super.getHeader(HttpHeaders.CONTENT_TYPE))){
return super.getInputStream();
}
//为空,直接返回
String json = IOUtils.toString(super.getInputStream(), "utf-8");
if (StringUtils.isBlank(json)) {
return super.getInputStream();
}
//xss过滤
json = xssEncode(json);
final ByteArrayInputStream bis = new ByteArrayInputStream(json.getBytes("utf-8"));
return new ServletInputStream() {
@Override
public boolean isFinished() {
return true;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
}
@Override
public int read() throws IOException {
return bis.read();
}
};
}
@Override
public String getParameter(String name) {
String value = super.getParameter(xssEncode(name));
if (StringUtils.isNotBlank(value)) {
value = xssEncode(value);
}
return value;
}
@Override
public String[] getParameterValues(String name) {
String[] parameters = super.getParameterValues(name);
if (parameters == null || parameters.length == 0) {
return null;
}
for (int i = 0; i < parameters.length; i++) {
parameters[i] = xssEncode(parameters[i]);
}
return parameters;
}
@Override
public Map<String,String[]> getParameterMap() {
Map<String,String[]> map = new LinkedHashMap<>();
Map<String,String[]> parameters = super.getParameterMap();
for (String key : parameters.keySet()) {
String[] values = parameters.get(key);
for (int i = 0; i < values.length; i++) {
values[i] = xssEncode(values[i]);
}
map.put(key, values);
}
return map;
}
@Override
public String getHeader(String name) {
String value = super.getHeader(xssEncode(name));
if (StringUtils.isNotBlank(value)) {
value = xssEncode(value);
}
return value;
}
private String xssEncode(String input) {
return htmlFilter.filter(input);
}
/**
* 获取最原始的request
*/
public HttpServletRequest getOrgRequest() {
return orgRequest;
}
/**
* 获取最原始的request
*/
public static HttpServletRequest getOrgRequest(HttpServletRequest request) {
if (request instanceof XssHttpServletRequestWrapper) {
return ((XssHttpServletRequestWrapper) request).getOrgRequest();
}
return request;
}
}

View File

@@ -0,0 +1,36 @@
package io.as.config;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 跨域配置:使用自定义 SimpleCorsFilter 在 Shiro 之前处理 OPTIONS 并回显 Origin(带凭证时不能用 *)。
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedHeaders("*")
.allowCredentials(false) // 仅用 Authorization,不涉及 Cookie
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.maxAge(3600);
}
/**
* 自定义 CORS 过滤器,最高优先级:OPTIONS 直接 200 + CORS 头,其它请求回显 Origin 以支持凭证。
*/
@Bean
public FilterRegistrationBean<SimpleCorsFilter> corsFilterRegistration() {
FilterRegistrationBean<SimpleCorsFilter> registration = new FilterRegistrationBean<>(new SimpleCorsFilter());
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
registration.addUrlPatterns("/*");
return registration;
}
}

View File

@@ -0,0 +1,41 @@
package io.as.config;
import io.as.common.xss.XssFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.DelegatingFilterProxy;
import javax.servlet.DispatcherType;
/**
* Filter配置
*
* @author Mark sunlightcs@gmail.com
*/
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean shiroFilterRegistration() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new DelegatingFilterProxy("shiroFilter"));
//该值缺省为false,表示生命周期由SpringApplicationContext管理,设置为true则表示由ServletContainer管理
registration.addInitParameter("targetFilterLifecycle", "true");
registration.setEnabled(true);
registration.setOrder(Integer.MAX_VALUE - 1);
registration.addUrlPatterns("/*");
return registration;
}
@Bean
public FilterRegistrationBean xssFilterRegistration() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setDispatcherTypes(DispatcherType.REQUEST);
registration.setFilter(new XssFilter());
registration.addUrlPatterns("/*");
registration.setName("xssFilter");
registration.setOrder(Integer.MAX_VALUE);
return registration;
}
}

View File

@@ -0,0 +1,25 @@
package io.as.config;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;
@Configuration
public class KaptchaConfig {
@Bean
public DefaultKaptcha producer() {
Properties properties = new Properties();
properties.put("kaptcha.border", "no");
properties.put("kaptcha.textproducer.font.color", "black");
properties.put("kaptcha.textproducer.char.space", "5");
properties.put("kaptcha.textproducer.font.names", "Arial,Courier,cmr10,宋体,楷体,微软雅黑");
Config config = new Config(properties);
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}

View File

@@ -0,0 +1,23 @@
package io.as.config;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* mybatis-plus配置
*
* @author Mark sunlightcs@gmail.com
*/
@Configuration
public class MybatisPlusConfig {
/**
* 分页插件
*/
@Bean
public PaginationInterceptor paginationInterceptor() {
return new PaginationInterceptor();
}
}

View File

@@ -0,0 +1,55 @@
package io.as.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.*;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis配置
*
* @author Mark sunlightcs@gmail.com
*/
@Configuration
public class RedisConfig {
@Autowired
private RedisConnectionFactory factory;
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(factory);
return redisTemplate;
}
@Bean
public HashOperations<String, String, Object> hashOperations(RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForHash();
}
@Bean
public ValueOperations<String, String> valueOperations(RedisTemplate<String, String> redisTemplate) {
return redisTemplate.opsForValue();
}
@Bean
public ListOperations<String, Object> listOperations(RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForList();
}
@Bean
public SetOperations<String, Object> setOperations(RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForSet();
}
@Bean
public ZSetOperations<String, Object> zSetOperations(RedisTemplate<String, Object> redisTemplate) {
return redisTemplate.opsForZSet();
}
}

View File

@@ -0,0 +1,74 @@
package io.as.config;
import io.as.modules.sys.jwt.JWTFilter;
import io.as.modules.sys.jwt.JWTRealm;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.Map;
@Configuration
public class ShiroConfig {
@Bean("securityManager")
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(jwtRealm());
securityManager.setRememberMeManager(null);
return securityManager;
}
@Bean
public JWTRealm jwtRealm() {
return new JWTRealm();
}
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
Map<String, Filter> filters = new LinkedHashMap<>();
filters.put("jwt", new JWTFilter());
shiroFilter.setFilters(filters);
Map<String, String> filterMap = new LinkedHashMap<>();
// 免认证路径
filterMap.put("/webjars/**", "anon");
filterMap.put("/druid/**", "anon");
filterMap.put("/sys/login", "anon");
filterMap.put("/sys/refresh", "anon");
filterMap.put("/captcha.jpg", "anon");
filterMap.put("/openapi/**", "anon");
filterMap.put("/api/jst/**", "anon");
filterMap.put("/api/payment/**", "anon");
filterMap.put("/swagger/**", "anon");
filterMap.put("/v2/api-docs", "anon");
filterMap.put("/swagger-ui.html", "anon");
filterMap.put("/swagger-resources/**", "anon");
// 其他路径需要 JWT 认证
filterMap.put("/**", "jwt");
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
@Bean("lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}

View File

@@ -0,0 +1,49 @@
package io.as.config;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 跨域过滤器:在 Shiro 之前执行,对 OPTIONS 预检直接返回 200 并设置 CORS 头。
* 带凭证(cookie/token)时须回显请求的 Origin,不能使用 *,否则浏览器会拒绝。
*/
public class SimpleCorsFilter implements Filter {
private static final String ORIGIN = "Origin";
private static final String AC_ALLOW_ORIGIN = "Access-Control-Allow-Origin";
private static final String AC_ALLOW_CREDENTIALS = "Access-Control-Allow-Credentials";
private static final String AC_ALLOW_METHODS = "Access-Control-Allow-Methods";
private static final String AC_ALLOW_HEADERS = "Access-Control-Allow-Headers";
private static final String AC_MAX_AGE = "Access-Control-Max-Age";
private static final String AC_EXPOSE_HEADERS = "Access-Control-Expose-Headers";
private static final String ALLOW_METHODS = "GET, POST, PUT, DELETE, OPTIONS, PATCH";
private static final String ALLOW_HEADERS = "*";
private static final String MAX_AGE = "3600";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
String origin = req.getHeader(ORIGIN);
if (origin != null && !origin.isEmpty()) {
resp.setHeader(AC_ALLOW_ORIGIN, origin);
resp.setHeader(AC_ALLOW_CREDENTIALS, "false"); // 仅用 Authorization,不涉及 Cookie
}
resp.setHeader(AC_ALLOW_METHODS, ALLOW_METHODS);
resp.setHeader(AC_ALLOW_HEADERS, ALLOW_HEADERS);
resp.setHeader(AC_MAX_AGE, MAX_AGE);
resp.setHeader(AC_EXPOSE_HEADERS, ALLOW_HEADERS);
if ("OPTIONS".equalsIgnoreCase(req.getMethod())) {
resp.setStatus(HttpServletResponse.SC_OK);
return;
}
chain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,53 @@
package io.as.config;
import io.swagger.annotations.ApiOperation;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.ApiKey;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
import java.util.List;
import static com.google.common.collect.Lists.newArrayList;
@Configuration
@EnableSwagger2
public class SwaggerConfig implements WebMvcConfigurer {
@Bean
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.select()
//加了ApiOperation注解的类,才生成接口文档
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
//包下的类,才生成接口文档
//.apis(RequestHandlerSelectors.basePackage("io.as.controller"))
.paths(PathSelectors.any())
.build()
.securitySchemes(security());
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("爱视文化科技")
.description("爱视后台文档")
.termsOfServiceUrl("https://www.renren.io")
.version("3.0.0")
.build();
}
private List<ApiKey> security() {
return newArrayList(
new ApiKey("Authorization", "Authorization", "header")
);
}
}

View File

@@ -0,0 +1,16 @@
package io.as.datasource.annotation;
import java.lang.annotation.*;
/**
* 多数据源注解
*
* @author Mark sunlightcs@gmail.com
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface DataSource {
String value() default "";
}

View File

@@ -0,0 +1,63 @@
package io.as.datasource.aspect;
import io.as.datasource.annotation.DataSource;
import io.as.datasource.config.DynamicContextHolder;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
/**
* 多数据源,切面处理类
*
* @author Mark sunlightcs@gmail.com
*/
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class DataSourceAspect {
protected Logger logger = LoggerFactory.getLogger(getClass());
@Pointcut("@annotation(io.as.datasource.annotation.DataSource) " +
"|| @within(io.as.datasource.annotation.DataSource)")
public void dataSourcePointCut() {
}
@Around("dataSourcePointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Class targetClass = point.getTarget().getClass();
Method method = signature.getMethod();
DataSource targetDataSource = (DataSource)targetClass.getAnnotation(DataSource.class);
DataSource methodDataSource = method.getAnnotation(DataSource.class);
if(targetDataSource != null || methodDataSource != null){
String value;
if(methodDataSource != null){
value = methodDataSource.value();
}else {
value = targetDataSource.value();
}
DynamicContextHolder.push(value);
logger.debug("set datasource is {}", value);
}
try {
return point.proceed();
} finally {
DynamicContextHolder.poll();
logger.debug("clean datasource");
}
}
}

View File

@@ -0,0 +1,49 @@
package io.as.datasource.config;
import java.util.ArrayDeque;
import java.util.Deque;
/**
* 多数据源上下文
*
* @author Mark sunlightcs@gmail.com
*/
public class DynamicContextHolder {
@SuppressWarnings("unchecked")
private static final ThreadLocal<Deque<String>> CONTEXT_HOLDER = new ThreadLocal() {
@Override
protected Object initialValue() {
return new ArrayDeque();
}
};
/**
* 获得当前线程数据源
*
* @return 数据源名称
*/
public static String peek() {
return CONTEXT_HOLDER.get().peek();
}
/**
* 设置当前线程数据源
*
* @param dataSource 数据源名称
*/
public static void push(String dataSource) {
CONTEXT_HOLDER.get().push(dataSource);
}
/**
* 清空当前线程数据源
*/
public static void poll() {
Deque<String> deque = CONTEXT_HOLDER.get();
deque.poll();
if (deque.isEmpty()) {
CONTEXT_HOLDER.remove();
}
}
}

View File

@@ -0,0 +1,17 @@
package io.as.datasource.config;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
/**
* 多数据源
*
* @author Mark sunlightcs@gmail.com
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DynamicContextHolder.peek();
}
}

View File

@@ -0,0 +1,55 @@
package io.as.datasource.config;
import com.alibaba.druid.pool.DruidDataSource;
import io.as.datasource.properties.DataSourceProperties;
import io.as.datasource.properties.DynamicDataSourceProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
/**
* 配置多数据源
*
* @author Mark sunlightcs@gmail.com
*/
@Configuration
@EnableConfigurationProperties(DynamicDataSourceProperties.class)
public class DynamicDataSourceConfig {
@Autowired
private DynamicDataSourceProperties properties;
@Bean
@ConfigurationProperties(prefix = "spring.datasource.druid")
public DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}
@Bean
public DynamicDataSource dynamicDataSource(DataSourceProperties dataSourceProperties) {
DynamicDataSource dynamicDataSource = new DynamicDataSource();
dynamicDataSource.setTargetDataSources(getDynamicDataSource());
//默认数据源
DruidDataSource defaultDataSource = DynamicDataSourceFactory.buildDruidDataSource(dataSourceProperties);
dynamicDataSource.setDefaultTargetDataSource(defaultDataSource);
return dynamicDataSource;
}
private Map<Object, Object> getDynamicDataSource(){
Map<String, DataSourceProperties> dataSourcePropertiesMap = properties.getDatasource();
Map<Object, Object> targetDataSources = new HashMap<>(dataSourcePropertiesMap.size());
dataSourcePropertiesMap.forEach((k, v) -> {
DruidDataSource druidDataSource = DynamicDataSourceFactory.buildDruidDataSource(v);
targetDataSources.put(k, druidDataSource);
});
return targetDataSources;
}
}

View File

@@ -0,0 +1,46 @@
package io.as.datasource.config;
import com.alibaba.druid.pool.DruidDataSource;
import io.as.datasource.properties.DataSourceProperties;
import java.sql.SQLException;
/**
* DruidDataSource
*
* @author Mark sunlightcs@gmail.com
* @since 1.0.0
*/
public class DynamicDataSourceFactory {
public static DruidDataSource buildDruidDataSource(DataSourceProperties properties) {
DruidDataSource druidDataSource = new DruidDataSource();
druidDataSource.setDriverClassName(properties.getDriverClassName());
druidDataSource.setUrl(properties.getUrl());
druidDataSource.setUsername(properties.getUsername());
druidDataSource.setPassword(properties.getPassword());
druidDataSource.setInitialSize(properties.getInitialSize());
druidDataSource.setMaxActive(properties.getMaxActive());
druidDataSource.setMinIdle(properties.getMinIdle());
druidDataSource.setMaxWait(properties.getMaxWait());
druidDataSource.setTimeBetweenEvictionRunsMillis(properties.getTimeBetweenEvictionRunsMillis());
druidDataSource.setMinEvictableIdleTimeMillis(properties.getMinEvictableIdleTimeMillis());
druidDataSource.setMaxEvictableIdleTimeMillis(properties.getMaxEvictableIdleTimeMillis());
druidDataSource.setValidationQuery(properties.getValidationQuery());
druidDataSource.setValidationQueryTimeout(properties.getValidationQueryTimeout());
druidDataSource.setTestOnBorrow(properties.isTestOnBorrow());
druidDataSource.setTestOnReturn(properties.isTestOnReturn());
druidDataSource.setPoolPreparedStatements(properties.isPoolPreparedStatements());
druidDataSource.setMaxOpenPreparedStatements(properties.getMaxOpenPreparedStatements());
druidDataSource.setSharePreparedStatements(properties.isSharePreparedStatements());
try {
druidDataSource.setFilters(properties.getFilters());
druidDataSource.init();
} catch (SQLException e) {
e.printStackTrace();
}
return druidDataSource;
}
}

View File

@@ -0,0 +1,194 @@
package io.as.datasource.properties;
/**
* 多数据源属性
*
* @author Mark sunlightcs@gmail.com
* @since 1.0.0
*/
public class DataSourceProperties {
private String driverClassName;
private String url;
private String username;
private String password;
/**
* Druid默认参数
*/
private int initialSize = 2;
private int maxActive = 10;
private int minIdle = -1;
private long maxWait = 60 * 1000L;
private long timeBetweenEvictionRunsMillis = 60 * 1000L;
private long minEvictableIdleTimeMillis = 1000L * 60L * 30L;
private long maxEvictableIdleTimeMillis = 1000L * 60L * 60L * 7;
private String validationQuery = "select 1";
private int validationQueryTimeout = -1;
private boolean testOnBorrow = false;
private boolean testOnReturn = false;
private boolean testWhileIdle = true;
private boolean poolPreparedStatements = false;
private int maxOpenPreparedStatements = -1;
private boolean sharePreparedStatements = false;
private String filters = "stat";
public String getDriverClassName() {
return driverClassName;
}
public void setDriverClassName(String driverClassName) {
this.driverClassName = driverClassName;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public int getInitialSize() {
return initialSize;
}
public void setInitialSize(int initialSize) {
this.initialSize = initialSize;
}
public int getMaxActive() {
return maxActive;
}
public void setMaxActive(int maxActive) {
this.maxActive = maxActive;
}
public int getMinIdle() {
return minIdle;
}
public void setMinIdle(int minIdle) {
this.minIdle = minIdle;
}
public long getMaxWait() {
return maxWait;
}
public void setMaxWait(long maxWait) {
this.maxWait = maxWait;
}
public long getTimeBetweenEvictionRunsMillis() {
return timeBetweenEvictionRunsMillis;
}
public void setTimeBetweenEvictionRunsMillis(long timeBetweenEvictionRunsMillis) {
this.timeBetweenEvictionRunsMillis = timeBetweenEvictionRunsMillis;
}
public long getMinEvictableIdleTimeMillis() {
return minEvictableIdleTimeMillis;
}
public void setMinEvictableIdleTimeMillis(long minEvictableIdleTimeMillis) {
this.minEvictableIdleTimeMillis = minEvictableIdleTimeMillis;
}
public long getMaxEvictableIdleTimeMillis() {
return maxEvictableIdleTimeMillis;
}
public void setMaxEvictableIdleTimeMillis(long maxEvictableIdleTimeMillis) {
this.maxEvictableIdleTimeMillis = maxEvictableIdleTimeMillis;
}
public String getValidationQuery() {
return validationQuery;
}
public void setValidationQuery(String validationQuery) {
this.validationQuery = validationQuery;
}
public int getValidationQueryTimeout() {
return validationQueryTimeout;
}
public void setValidationQueryTimeout(int validationQueryTimeout) {
this.validationQueryTimeout = validationQueryTimeout;
}
public boolean isTestOnBorrow() {
return testOnBorrow;
}
public void setTestOnBorrow(boolean testOnBorrow) {
this.testOnBorrow = testOnBorrow;
}
public boolean isTestOnReturn() {
return testOnReturn;
}
public void setTestOnReturn(boolean testOnReturn) {
this.testOnReturn = testOnReturn;
}
public boolean isTestWhileIdle() {
return testWhileIdle;
}
public void setTestWhileIdle(boolean testWhileIdle) {
this.testWhileIdle = testWhileIdle;
}
public boolean isPoolPreparedStatements() {
return poolPreparedStatements;
}
public void setPoolPreparedStatements(boolean poolPreparedStatements) {
this.poolPreparedStatements = poolPreparedStatements;
}
public int getMaxOpenPreparedStatements() {
return maxOpenPreparedStatements;
}
public void setMaxOpenPreparedStatements(int maxOpenPreparedStatements) {
this.maxOpenPreparedStatements = maxOpenPreparedStatements;
}
public boolean isSharePreparedStatements() {
return sharePreparedStatements;
}
public void setSharePreparedStatements(boolean sharePreparedStatements) {
this.sharePreparedStatements = sharePreparedStatements;
}
public String getFilters() {
return filters;
}
public void setFilters(String filters) {
this.filters = filters;
}
}

View File

@@ -0,0 +1,25 @@
package io.as.datasource.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 多数据源属性
*
* @author Mark sunlightcs@gmail.com
* @since 1.0.0
*/
@ConfigurationProperties(prefix = "dynamic")
public class DynamicDataSourceProperties {
private Map<String, DataSourceProperties> datasource = new LinkedHashMap<>();
public Map<String, DataSourceProperties> getDatasource() {
return datasource;
}
public void setDatasource(Map<String, DataSourceProperties> datasource) {
this.datasource = datasource;
}
}

View File

@@ -0,0 +1,166 @@
package io.as.modules.daycard.constants;
import java.math.BigDecimal;
/**
* 天卡业务相关常量
*/
public final class DayCardConstants {
private DayCardConstants() {
}
// ==================== 订单状态 ====================
/** 已创建 */
public static final String ORDER_STATUS_CREATED = "PENDING";
/** 支付中 */
public static final String ORDER_STATUS_PAYING = "PAYING";
/** 已支付 */
public static final String ORDER_STATUS_PAID = "PAID";
/** 支付失败 */
public static final String ORDER_STATUS_PAY_FAILED = "PAY_FAILED";
/** 已取消 */
public static final String ORDER_STATUS_CANCELLED = "CANCELLED";
/** 退款中 */
public static final String ORDER_STATUS_REFUNDING = "REFUNDING";
// ==================== 天卡池状态 ====================
/** 未使用 */
public static final String POOL_STATUS_UNUSED = "UNUSED";
/** 已预留 */
public static final String POOL_STATUS_RESERVED = "RESERVED";
/** 已使用 */
public static final String POOL_STATUS_USED = "USED";
/** 可复用(已解绑,橘视厅权益仍有效) */
public static final String POOL_STATUS_REUSABLE = "REUSABLE";
// ==================== 导入批次状态 ====================
/** 处理中 */
public static final String BATCH_STATUS_PROCESSING = "PROCESSING";
/** 成功 */
public static final String BATCH_STATUS_SUCCEEDED = "SUCCEEDED";
/** 部分失败 */
public static final String BATCH_STATUS_PARTIAL_FAILED = "PARTIAL_FAILED";
/** 失败 */
public static final String BATCH_STATUS_FAILED = "FAILED";
// ==================== 激活状态 ====================
/** 待激活 */
public static final String ACTIVATE_STATUS_PENDING = "PENDING";
/** 激活成功 */
public static final String ACTIVATE_STATUS_SUCCESS = "SUCCESS";
/** 激活失败 */
public static final String ACTIVATE_STATUS_FAILED = "FAILED";
// ==================== 卡类型 ====================
/** 天卡 */
public static final String CARD_TYPE_DAY = "DAY";
// ==================== Redis Key 前缀 ====================
/** 设备防重锁前缀 */
public static final String REDIS_LOCK_PREFIX = "daycard:lock:device:";
/** 订单前缀 */
public static final String REDIS_ORDER_PREFIX = "daycard:order:";
/** 设备订单前缀 */
public static final String REDIS_DEVICE_ORDER_PREFIX = "daycard:device:";
/** 代销天卡消耗防重锁前缀(按设备标识加锁) */
public static final String REDIS_PARTNER_CONSUME_LOCK_PREFIX = "daycard:partner:consume:";
/** 第三方激活Token缓存前缀(按thirdCardNo缓存) */
public static final String REDIS_THIRD_PARTY_TOKEN_PREFIX = "api:thirdparty:activate:";
/** 第三方激活任务锁前缀(按poolId加锁,防止重复激活) */
public static final String REDIS_THIRD_PARTY_ACTIVATE_LOCK_PREFIX = "api:thirdparty:activate:lock:";
// ==================== 删除标记 ====================
/** 未删除 */
public static final String DELETE_FLAG_UNDEL = "UNDEL";
/** 已删除 */
public static final String DELETE_FLAG_DEL = "DEL";
// ==================== 分润规则范围 ====================
/** 全局 */
public static final String SCOPE_GLOBAL = "GLOBAL";
/** 渠道 */
public static final String SCOPE_CHANNEL = "CHANNEL";
/** 代理商 */
public static final String SCOPE_AGENT = "AGENT";
/** 门店 */
public static final String SCOPE_STORE = "STORE";
// ==================== 分润规则状态 ====================
/** 启用 */
public static final String PROFIT_RULE_STATUS_ACTIVE = "ACTIVE";
/** 禁用 */
public static final String PROFIT_RULE_STATUS_DISABLED = "DISABLED";
// ==================== 分润类型 ====================
/** 按比例分润 */
public static final String SHARE_TYPE_RATIO = "RATIO";
/** 固定金额分润 */
public static final String SHARE_TYPE_FIXED = "FIXED";
// ==================== 分账单状态 ====================
/** 待处理 */
public static final String SETTLE_STATUS_PENDING = "PENDING";
/** 处理中 */
public static final String SETTLE_STATUS_PROCESSING = "PROCESSING";
/** 成功 */
public static final String SETTLE_STATUS_SUCCESS = "SUCCESS";
/** 失败 */
public static final String SETTLE_STATUS_FAILED = "FAILED";
/** 部分成功 */
public static final String SETTLE_STATUS_PARTIAL = "PARTIAL";
// ==================== 接收方绑定状态 ====================
/** 待绑定 */
public static final String BIND_STATUS_PENDING = "PENDING";
/** 已绑定 */
public static final String BIND_STATUS_ACTIVE = "ACTIVE";
/** 绑定失败 */
public static final String BIND_STATUS_FAILED = "FAILED";
// ==================== 分账接收方类型 ====================
/** 商户号 */
public static final String RECEIVER_TYPE_MERCHANT_ID = "MERCHANT_ID";
/** 个人OpenID */
public static final String RECEIVER_TYPE_PERSONAL_OPENID = "PERSONAL_OPENID";
// ==================== 组织角色 ====================
/** 平台 */
public static final String ORG_ROLE_PLATFORM = "PLATFORM";
/** 渠道 */
public static final String ORG_ROLE_CHANNEL = "CHANNEL";
/** 代理 */
public static final String ORG_ROLE_AGENT = "AGENT";
/** 门店 */
public static final String ORG_ROLE_STORE = "STORE";
// ==================== 微信绑定状态 ====================
/** 已绑定 */
public static final String WX_BIND_STATUS_BINDED = "BINDED";
/** 未绑定 */
public static final String WX_BIND_STATUS_UNBOUND = "UNBOUND";
// ==================== 分润比例上限 ====================
/** 分润比例上限:30% */
public static final BigDecimal SHARE_RATIO_LIMIT = new BigDecimal("0.3000");
/** 非平台方分润比例之和上限:30% */
public static final double PROFIT_SHARE_LIMIT = 30.0;
// ==================== 退款状态 ====================
/** 退款待处理 */
public static final String REFUND_STATUS_PENDING = "PENDING";
/** 退款处理中 */
public static final String REFUND_STATUS_PROCESSING = "PROCESSING";
/** 退款成功 */
public static final String REFUND_STATUS_SUCCESS = "SUCCESS";
/** 退款失败 */
public static final String REFUND_STATUS_FAILED = "FAILED";
/** 退款关闭 */
public static final String REFUND_STATUS_CLOSED = "CLOSED";
// ==================== 支付渠道 ====================
/** Mock支付 */
public static final String PAY_CHANNEL_MOCK = "MOCK";
/** 微信支付 */
public static final String PAY_CHANNEL_WECHAT = "WECHAT";
}

View File

@@ -0,0 +1,165 @@
package io.as.modules.daycard.controller;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.common.utils.R;
import io.as.modules.daycard.entity.DayCardActivationCodeEntity;
import io.as.modules.daycard.service.DayCardActivationCodeService;
import io.as.modules.sys.entity.SysOrgEntity;
import io.as.modules.sys.service.SysOrgService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 天卡设备激活码管理(管理后台用)
* <p>管理后台按门店生成激活码,App 端 init 时输入激活码完成设备注册绑定</p>
*/
@Api(tags = "天卡设备激活码")
@RestController
@RequestMapping("/manage/daycard/activation-code")
public class DayCardActivationCodeController {
@Autowired
private DayCardActivationCodeService dayCardActivationCodeService;
@Autowired
private SysOrgService sysOrgService;
/**
* 按门店查询激活码列表
*/
@ApiOperation("按门店查询激活码列表")
@GetMapping("/list/{storeOrgId}")
public R listByStore(@PathVariable("storeOrgId") Long storeOrgId) {
List<DayCardActivationCodeEntity> list = dayCardActivationCodeService.listByStoreOrgId(storeOrgId);
List<Map<String, Object>> result = new ArrayList<>();
for (DayCardActivationCodeEntity entity : list) {
result.add(toMap(entity));
}
Map<String, Object> data = new HashMap<>();
data.put("list", result);
return R.dataSuccess(data);
}
/**
* 为门店生成激活码(每门店一个,已存在则返回已有的)
*/
@ApiOperation("为门店生成激活码")
@AuditAction(value = "ACTCODE_GENERATE", description = "生成门店激活码")
@PostMapping("/generate")
public R generate(@RequestBody GenerateRequest request) {
if (request.getStoreOrgId() == null) {
throw new ASException("门店组织ID不能为空");
}
// 校验门店存在
SysOrgEntity storeOrg = sysOrgService.getById(request.getStoreOrgId());
if (storeOrg == null) {
throw new ASException("门店不存在");
}
// 从门店 mpath 解析渠道
Long channelOrgId = resolveChannelOrgId(storeOrg);
DayCardActivationCodeEntity entity = dayCardActivationCodeService.generateForStore(
request.getStoreOrgId(), channelOrgId, request.getRemark());
Map<String, Object> data = new HashMap<>();
data.put("data", toMap(entity));
return R.dataSuccess(data);
}
/**
* 重新生成激活码 code(旧 code 失效)
*/
@ApiOperation("重新生成激活码")
@AuditAction(value = "ACTCODE_REGENERATE", description = "重新生成激活码")
@PostMapping("/{id}/regenerate")
public R regenerate(@PathVariable("id") Long id) {
String newCode = dayCardActivationCodeService.regenerateCode(id);
Map<String, Object> item = new HashMap<>();
item.put("code", newCode);
Map<String, Object> data = new HashMap<>();
data.put("data", item);
return R.dataSuccess(data);
}
/**
* 启用/禁用激活码
*/
@ApiOperation("启用/禁用激活码")
@AuditAction(value = "ACTCODE_UPDATE_STATUS", description = "修改激活码状态")
@PutMapping("/{id}/status")
public R updateStatus(@PathVariable("id") Long id, @RequestBody UpdateStatusRequest request) {
dayCardActivationCodeService.updateStatus(id, request.getStatus());
return R.ok();
}
/**
* 删除激活码(软删除)
*/
@ApiOperation("删除激活码")
@AuditAction(value = "ACTCODE_DELETE", description = "删除激活码")
@DeleteMapping("/{id}")
public R delete(@PathVariable("id") Long id) {
dayCardActivationCodeService.delete(id);
return R.ok();
}
// ==================== 内部方法 ====================
/**
* 从门店 mpath 解析渠道组织ID(mpath 第2段)
*/
private Long resolveChannelOrgId(SysOrgEntity storeOrg) {
if (storeOrg == null || StringUtils.isBlank(storeOrg.getMpath())) {
return null;
}
String[] segments = storeOrg.getMpath().split("\\.");
if (segments.length >= 2) {
try {
return Long.parseLong(segments[1]);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private Map<String, Object> toMap(DayCardActivationCodeEntity entity) {
Map<String, Object> m = new HashMap<>();
m.put("id", entity.getId());
m.put("code", entity.getCode());
m.put("channelOrgId", entity.getChannelOrgId());
m.put("storeOrgId", entity.getStoreOrgId());
m.put("status", entity.getStatus());
m.put("maxDeviceCount", entity.getMaxDeviceCount());
m.put("activatedCount", entity.getActivatedCount());
m.put("remark", entity.getRemark());
m.put("createdAt", entity.getCreatedAt());
return m;
}
/**
* 生成激活码请求
*/
@lombok.Data
public static class GenerateRequest {
private Long storeOrgId;
private String remark;
}
/**
* 更新状态请求
*/
@lombok.Data
public static class UpdateStatusRequest {
private String status;
}
}

View File

@@ -0,0 +1,192 @@
package io.as.modules.daycard.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.as.common.annotation.AuditAction;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.entity.DayCardPoolEntity;
import io.as.modules.daycard.service.DayCardOrderService;
import io.as.modules.daycard.service.DayCardPoolService;
import io.as.modules.daycard.vo.DayCardPoolStatsVo;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 天卡管理后台接口(JWT 鉴权)
* <p>供 Web 管理后台调用,与 /openapi/** 合作伙伴接口隔离</p>
*/
@RestController
@RequestMapping("/manage/daycard")
@RequiresAuthentication
public class DayCardManageController {
@Autowired
private DayCardPoolService dayCardPoolService;
@Autowired
private DayCardOrderService dayCardOrderService;
// ===================== 天卡池 =====================
/**
* 天卡池列表
*/
@GetMapping("/pool/list")
public R poolList(@RequestParam Map<String, Object> params) {
Long partnerId = parsePartnerId(params);
PageUtils page = dayCardPoolService.queryPage(params, partnerId);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 天卡池统计
*/
@GetMapping("/pool/stats")
public R poolStats(@RequestParam(required = false) Long partnerId) {
DayCardPoolStatsVo vo = buildStats(partnerId);
Map<String, Object> data = new HashMap<>();
data.put("stats", vo);
return R.dataSuccess(data);
}
/**
* 导入天卡卡密
*/
@AuditAction(value = "DAYCARD_POOL_IMPORT", description = "导入天卡卡密")
@PostMapping("/pool/import")
public R poolImport(@RequestBody Map<String, Object> body) {
Long partnerId = null;
Object pid = body.get("partnerId");
if (pid != null) {
partnerId = Long.parseLong(String.valueOf(pid));
}
Object codesObj = body.get("cardCodes");
if (codesObj == null) {
return R.error("cardCodes 不能为空");
}
@SuppressWarnings("unchecked")
List<String> cardCodes = (List<String>) codesObj;
if (cardCodes.isEmpty()) {
return R.error("cardCodes 不能为空");
}
if (partnerId == null) {
return R.error("partnerId 不能为空");
}
int success = dayCardPoolService.importCards(cardCodes, partnerId);
Map<String, Object> data = new HashMap<>();
data.put("successCount", success);
data.put("totalCount", cardCodes.size());
return R.dataSuccess(data);
}
// ===================== 天卡订单 =====================
/**
* 天卡订单列表
*/
@GetMapping("/order/list")
public R orderList(@RequestParam Map<String, Object> params) {
Long partnerId = parsePartnerId(params);
PageUtils page = dayCardOrderService.queryPage(params, partnerId);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
// ===================== 设备权益 =====================
/**
* 查询设备天卡权益
*/
@GetMapping("/benefits/{deviceId}")
public R deviceBenefits(@PathVariable Long deviceId) {
DayCardPoolEntity pool = dayCardOrderService.getDeviceBenefits(deviceId);
Map<String, Object> item = new HashMap<>();
if (pool != null) {
item.put("hasBenefits", true);
item.put("thirdCardNo", pool.getThirdCardNo());
item.put("cardCode", pool.getCardCode());
item.put("expireAt", pool.getExpireAt());
} else {
item.put("hasBenefits", false);
}
Map<String, Object> data = new HashMap<>();
data.put("data", item);
return R.dataSuccess(data);
}
/**
* 解绑设备天卡权益(Web端手动操作)
* <p>将设备绑定的 USED 卡转为 REUSABLE,释放设备。橘视厅权益保留,后续新设备下单可复用此卡。</p>
*/
@AuditAction(value = "DAYCARD_DEVICE_UNBIND", description = "解绑设备天卡权益")
@PostMapping("/device/unbind")
public R unbindDevice(@RequestParam Long deviceId) {
dayCardOrderService.unbindDevice(deviceId);
return R.ok("设备解绑成功");
}
/**
* 设备组织解绑(更换安装场所时调用)
* <p>释放设备当前天卡权益并清除门店组织关联(storeOrgId),设备可在新场所用新激活码重新激活绑定。</p>
*/
@AuditAction(value = "DAYCARD_DEVICE_UNBIND_ORG", description = "设备组织解绑")
@PostMapping("/device/unbind-org")
public R unbindDeviceOrg(@RequestParam Long deviceId) {
dayCardOrderService.unbindDeviceOrg(deviceId);
return R.ok("设备组织解绑成功");
}
// ===================== 内部方法 =====================
private Long parsePartnerId(Map<String, Object> params) {
Object val = params.get("partnerId");
if (val == null) {
val = params.get("partner_id");
}
if (val == null || "".equals(val)) {
return null;
}
try {
return Long.parseLong(String.valueOf(val));
} catch (NumberFormatException e) {
return null;
}
}
private DayCardPoolStatsVo buildStats(Long partnerId) {
DayCardPoolStatsVo vo = new DayCardPoolStatsVo();
int available = dayCardPoolService.getAvailableCount(partnerId);
vo.setAvailable(available);
QueryWrapper<DayCardPoolEntity> reservedWrapper = new QueryWrapper<>();
reservedWrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.eq("status", DayCardConstants.POOL_STATUS_RESERVED);
if (partnerId != null) {
reservedWrapper.eq("partner_id", partnerId);
}
int reserved = dayCardPoolService.count(reservedWrapper);
vo.setReserved(reserved);
QueryWrapper<DayCardPoolEntity> usedWrapper = new QueryWrapper<>();
usedWrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.eq("status", DayCardConstants.POOL_STATUS_USED);
if (partnerId != null) {
usedWrapper.eq("partner_id", partnerId);
}
int used = dayCardPoolService.count(usedWrapper);
vo.setUsed(used);
vo.setTotal(available + reserved + used);
return vo;
}
}

View File

@@ -0,0 +1,73 @@
package io.as.modules.daycard.controller;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.entity.DayCardOrderEntity;
import io.as.modules.daycard.service.DayCardOrderService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 天卡订单 Controller(管理后台用)
* <p>App 端接口已迁移至 /api/jst/** (ApiJstController),本 Controller 仅保留管理后台用的分页查询和详情接口</p>
*/
@Api(tags = "天卡订单")
@RestController
@RequestMapping("/manage/daycard")
public class DayCardOrderController {
@Autowired
private DayCardOrderService dayCardOrderService;
/**
* 分页查询订单
*/
@ApiOperation("分页查询天卡订单")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
Long partnerId = parsePartnerId(params);
PageUtils page = dayCardOrderService.queryPage(params, partnerId);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 查询订单详情
*/
@ApiOperation("查询订单详情")
@GetMapping("/info/{orderNo}")
public R info(@PathVariable("orderNo") String orderNo) {
DayCardOrderEntity order = dayCardOrderService.getByOrderNo(orderNo);
if (order == null) {
throw new ASException("订单不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("order", order);
return R.dataSuccess(data);
}
/**
* 从请求参数中解析 partnerId(兼容存量数据查询,新订单 partnerId 为 null)
*/
private Long parsePartnerId(Map<String, Object> params) {
Object val = params.get("partnerId");
if (val == null) {
val = params.get("partner_id");
}
if (val == null) {
return null;
}
try {
return Long.parseLong(String.valueOf(val));
} catch (NumberFormatException e) {
return null;
}
}
}

View File

@@ -0,0 +1,129 @@
package io.as.modules.daycard.controller;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.entity.DayCardPriceConfigEntity;
import io.as.modules.daycard.service.DayCardPriceConfigService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 天卡定价策略管理 Controller
*/
@Api(tags = "天卡定价策略管理")
@RestController
@RequestMapping("/manage/price")
public class DayCardPriceConfigController {
@Autowired
private DayCardPriceConfigService dayCardPriceConfigService;
/**
* 分页查询定价策略(支持 partnerId/status 筛选)
*/
@ApiOperation("分页查询定价策略")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
PageUtils page = dayCardPriceConfigService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 查询定价策略详情
*/
@ApiOperation("定价策略详情")
@GetMapping("/info/{id}")
public R info(@PathVariable("id") Long id) {
DayCardPriceConfigEntity config = dayCardPriceConfigService.getById(id);
if (config == null) {
throw new ASException("定价策略不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("config", config);
return R.dataSuccess(data);
}
/**
* 查询生效中的定价策略(3 层维度)
* <p>优先级:门店配置价 > 渠道配置价 > 平台默认价兜底</p>
*
* @param channelOrgId 渠道组织ID(可空)
* @param storeOrgId 门店组织ID(可空)
*/
@ApiOperation("查询生效中的定价策略")
@GetMapping("/effective")
public R effective(@RequestParam(value = "channelOrgId", required = false) Long channelOrgId,
@RequestParam(value = "storeOrgId", required = false) Long storeOrgId) {
DayCardPriceConfigEntity config = dayCardPriceConfigService.getEffectivePrice(channelOrgId, storeOrgId);
Map<String, Object> data = new HashMap<>();
data.put("config", config);
return R.dataSuccess(data);
}
/**
* 新增定价策略
*/
@ApiOperation("新增定价策略")
@AuditAction(value = "PRICE_SAVE", description = "新增定价策略")
@PostMapping("/save")
public R save(@RequestBody DayCardPriceConfigEntity config) {
Date now = new Date();
config.setDeleteFlag(DayCardConstants.DELETE_FLAG_UNDEL);
config.setCreatedAt(now);
config.setUpdatedAt(now);
if (config.getStatus() == null) {
config.setStatus("ACTIVE");
}
dayCardPriceConfigService.save(config);
return R.ok();
}
/**
* 修改定价策略
*/
@ApiOperation("修改定价策略")
@AuditAction(value = "PRICE_UPDATE", description = "修改定价策略")
@PostMapping("/update")
public R update(@RequestBody DayCardPriceConfigEntity config) {
if (config.getId() == null) {
throw new ASException("id不能为空");
}
config.setUpdatedAt(new Date());
dayCardPriceConfigService.updateById(config);
return R.ok();
}
/**
* 软删除定价策略
*/
@ApiOperation("删除定价策略")
@AuditAction(value = "PRICE_DELETE", description = "删除定价策略")
@PostMapping("/delete")
public R delete(@RequestBody Long[] ids) {
if (ids == null || ids.length == 0) {
throw new ASException("ids不能为空");
}
List<DayCardPriceConfigEntity> list = dayCardPriceConfigService.listByIds(
Arrays.asList(ids));
Date now = new Date();
for (DayCardPriceConfigEntity config : list) {
config.setDeleteFlag(DayCardConstants.DELETE_FLAG_DEL);
config.setUpdatedAt(now);
}
dayCardPriceConfigService.updateBatchById(list);
return R.ok();
}
}

View File

@@ -0,0 +1,75 @@
package io.as.modules.daycard.controller;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.entity.DayCardRefundEntity;
import io.as.modules.daycard.service.DayCardRefundService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 天卡退款管理 Controller
*/
@Api(tags = "天卡退款管理")
@RestController
@RequestMapping("/manage/refund")
public class DayCardRefundController {
@Autowired
private DayCardRefundService dayCardRefundService;
/**
* 分页查询退款记录
*/
@ApiOperation("分页查询退款记录")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
PageUtils page = dayCardRefundService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 退款详情
*/
@ApiOperation("退款详情")
@GetMapping("/info/{refundNo}")
public R info(@PathVariable("refundNo") String refundNo) {
DayCardRefundEntity refund = dayCardRefundService.getByRefundNo(refundNo);
if (refund == null) {
throw new ASException("退款记录不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("refund", refund);
return R.dataSuccess(data);
}
/**
* 发起退款
*
* @param params 包含 orderNo(订单号)和 reason(退款原因)
*/
@ApiOperation("发起退款")
@AuditAction(value = "REFUND_APPLY", description = "发起退款")
@PostMapping("/apply")
public R apply(@RequestBody Map<String, String> params) {
String orderNo = params.get("orderNo");
if (StringUtils.isBlank(orderNo)) {
throw new ASException("orderNo不能为空");
}
String reason = params.get("reason");
DayCardRefundEntity refund = dayCardRefundService.refund(orderNo, reason);
Map<String, Object> data = new HashMap<>();
data.put("refund", refund);
return R.dataSuccess(data);
}
}

View File

@@ -0,0 +1,192 @@
package io.as.modules.daycard.controller;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.entity.DayCardSettleOrderEntity;
import io.as.modules.daycard.entity.DayCardSettleRecordEntity;
import io.as.modules.daycard.entity.DayCardSettleRuleEntity;
import io.as.modules.daycard.service.DayCardSettleOrderService;
import io.as.modules.daycard.service.DayCardSettleRecordService;
import io.as.modules.daycard.service.DayCardSettleRuleService;
import io.as.modules.daycard.service.ProfitCalcService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
/**
* 天卡分账管理 Controller
*/
@Api(tags = "天卡分账管理")
@RestController
@RequestMapping("/manage/settle")
public class DayCardSettleController {
@Autowired
private DayCardSettleRuleService dayCardSettleRuleService;
@Autowired
private DayCardSettleRecordService dayCardSettleRecordService;
@Autowired
private DayCardSettleOrderService dayCardSettleOrderService;
@Autowired
private ProfitCalcService profitCalcService;
// ==================== 分账规则 ====================
/**
* 分页查询分账规则
*/
@ApiOperation("分页查询分账规则")
@GetMapping("/rule/list")
public R ruleList(@RequestParam Map<String, Object> params) {
PageUtils page = dayCardSettleRuleService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 分账规则详情
*/
@ApiOperation("分账规则详情")
@GetMapping("/rule/info/{id}")
public R ruleInfo(@PathVariable("id") Long id) {
DayCardSettleRuleEntity rule = dayCardSettleRuleService.getById(id);
if (rule == null) {
throw new ASException("分账规则不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("rule", rule);
return R.dataSuccess(data);
}
/**
* 新增分账规则
*/
@ApiOperation("新增分账规则")
@PostMapping("/rule/save")
public R ruleSave(@RequestBody DayCardSettleRuleEntity rule) {
if (rule.getName() == null || rule.getName().trim().isEmpty()) {
throw new ASException("规则名称不能为空");
}
if (rule.getShareRatio() == null) {
throw new ASException("分账比例不能为空");
}
dayCardSettleRuleService.saveRule(rule);
Map<String, Object> data = new HashMap<>();
data.put("rule", rule);
return R.dataSuccess(data);
}
/**
* 修改分账规则
*/
@ApiOperation("修改分账规则")
@PostMapping("/rule/update")
public R ruleUpdate(@RequestBody DayCardSettleRuleEntity rule) {
if (rule.getId() == null) {
throw new ASException("id不能为空");
}
dayCardSettleRuleService.update(rule);
return R.ok();
}
/**
* 删除分账规则(软删除)
*/
@ApiOperation("删除分账规则")
@PostMapping("/rule/delete")
public R ruleDelete(@RequestBody Long[] ids) {
dayCardSettleRuleService.delete(ids);
return R.ok();
}
// ==================== 分账记录 ====================
/**
* 分页查询分账记录
*/
@ApiOperation("分页查询分账记录")
@GetMapping("/record/list")
public R recordList(@RequestParam Map<String, Object> params) {
PageUtils page = dayCardSettleRecordService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 分账记录详情
*/
@ApiOperation("分账记录详情")
@GetMapping("/record/info/{id}")
public R recordInfo(@PathVariable("id") Long id) {
DayCardSettleRecordEntity record = dayCardSettleRecordService.getById(id);
if (record == null) {
throw new ASException("分账记录不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("record", record);
return R.dataSuccess(data);
}
// ==================== 分账单 ====================
/**
* 分页查询分账单
*/
@ApiOperation("分页查询分账单")
@GetMapping("/order/list")
public R orderList(@RequestParam Map<String, Object> params) {
PageUtils page = dayCardSettleOrderService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 分账单详情
*/
@ApiOperation("分账单详情")
@GetMapping("/order/info/{id}")
public R orderInfo(@PathVariable("id") Long id) {
DayCardSettleOrderEntity settleOrder = dayCardSettleOrderService.getById(id);
if (settleOrder == null) {
throw new ASException("分账单不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("settleOrder", settleOrder);
return R.dataSuccess(data);
}
// ==================== 试算 ====================
/**
* 分润试算
*/
@ApiOperation("分润试算")
@GetMapping("/trial")
public R trial(@RequestParam("partnerId") Long partnerId,
@RequestParam(value = "channelOrgId", required = false) Long channelOrgId,
@RequestParam(value = "storeOrgId", required = false) Long storeOrgId,
@RequestParam("totalAmount") BigDecimal totalAmount) {
if (partnerId == null) {
throw new ASException("partnerId不能为空");
}
if (totalAmount == null || totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw new ASException("totalAmount必须大于0");
}
Map<String, Object> result = profitCalcService.calculate(partnerId, channelOrgId, storeOrgId, totalAmount);
Map<String, Object> data = new HashMap<>();
data.put("data", result);
return R.dataSuccess(data);
}
}

View File

@@ -0,0 +1,98 @@
package io.as.modules.daycard.controller;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.entity.OrgSettlementAccountEntity;
import io.as.modules.daycard.service.OrgSettlementAccountService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 组织分账接收方 Controller
*/
@Api(tags = "组织分账接收方管理")
@RestController
@RequestMapping("/manage/profit-receiver")
public class OrgSettlementAccountController {
@Autowired
private OrgSettlementAccountService orgSettlementAccountService;
/**
* 分页查询分账接收方
*/
@ApiOperation("分页查询分账接收方")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
PageUtils page = orgSettlementAccountService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 分账接收方详情
*/
@ApiOperation("分账接收方详情")
@GetMapping("/info/{id}")
public R info(@PathVariable("id") Long id) {
OrgSettlementAccountEntity account = orgSettlementAccountService.getById(id);
if (account == null) {
throw new ASException("分账接收方不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("account", account);
return R.dataSuccess(data);
}
/**
* 新增分账接收方
*/
@ApiOperation("新增分账接收方")
@AuditAction(value = "PROFIT_RECEIVER_SAVE", description = "新增分账接收方")
@PostMapping("/save")
public R save(@RequestBody OrgSettlementAccountEntity account) {
if (account.getOrgId() == null) {
throw new ASException("组织ID不能为空");
}
if (account.getReceiverAccount() == null || account.getReceiverAccount().trim().isEmpty()) {
throw new ASException("接收方账号不能为空");
}
if (account.getReceiverType() == null || account.getReceiverType().trim().isEmpty()) {
throw new ASException("接收方类型不能为空");
}
orgSettlementAccountService.saveAccount(account);
Map<String, Object> data = new HashMap<>();
data.put("account", account);
return R.dataSuccess(data);
}
/**
* 删除分账接收方(软删除)
*/
@ApiOperation("删除分账接收方")
@AuditAction(value = "PROFIT_RECEIVER_DELETE", description = "删除分账接收方")
@PostMapping("/delete")
public R delete(@RequestBody Long[] ids) {
orgSettlementAccountService.delete(ids);
return R.ok();
}
/**
* 同步接收方到微信
*/
@ApiOperation("同步接收方到微信")
@AuditAction(value = "PROFIT_RECEIVER_SYNC", description = "同步分账接收方到微信")
@PostMapping("/sync/{id}")
public R sync(@PathVariable("id") Long id) {
orgSettlementAccountService.syncToWechat(id);
return R.ok();
}
}

View File

@@ -0,0 +1,187 @@
package io.as.modules.daycard.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.common.utils.R;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.ProfitSharingDetailDao;
import io.as.modules.daycard.dao.ProfitSharingOrderDao;
import io.as.modules.daycard.entity.ProfitSharingDetailEntity;
import io.as.modules.daycard.entity.ProfitSharingOrderEntity;
import io.as.modules.sys.dao.SysOrgDao;
import io.as.modules.sys.entity.SysOrgEntity;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.*;
/**
* 分账记录查询 Controller
*/
@Api(tags = "分账记录查询")
@RestController
@RequestMapping("/manage/profit-sharing")
public class ProfitSharingQueryController {
@Autowired
private ProfitSharingOrderDao profitSharingOrderDao;
@Autowired
private ProfitSharingDetailDao profitSharingDetailDao;
@Autowired
private SysOrgDao sysOrgDao;
/**
* 分页查询分账单(支持 orderNo/status 筛选),返回详情列表
*/
@ApiOperation("分页查询分账单")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
IPage<ProfitSharingOrderEntity> page = new Query<ProfitSharingOrderEntity>().getPage(params, "created_at", false);
QueryWrapper<ProfitSharingOrderEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
String orderNo = (String) params.get("orderNo");
if (StringUtils.isNotBlank(orderNo)) {
wrapper.eq("pay_order_no", orderNo);
}
String status = (String) params.get("status");
if (StringUtils.isNotBlank(status)) {
wrapper.eq("status", status);
}
String outOrderNo = (String) params.get("outOrderNo");
if (StringUtils.isNotBlank(outOrderNo)) {
wrapper.eq("out_order_no", outOrderNo);
}
IPage<ProfitSharingOrderEntity> result = profitSharingOrderDao.selectPage(page, wrapper);
// 填充明细列表
for (ProfitSharingOrderEntity order : result.getRecords()) {
QueryWrapper<ProfitSharingDetailEntity> detailWrapper = new QueryWrapper<>();
detailWrapper.eq("sharing_order_id", order.getId());
List<ProfitSharingDetailEntity> details = profitSharingDetailDao.selectList(detailWrapper);
order.setDetails(details);
}
Map<String, Object> data = new HashMap<>();
data.put("page", new PageUtils(result));
return R.dataSuccess(data);
}
/**
* 分账单详情 + details 子表
*/
@ApiOperation("分账单详情")
@GetMapping("/info/{id}")
public R info(@PathVariable("id") Long id) {
ProfitSharingOrderEntity order = profitSharingOrderDao.selectById(id);
if (order == null) {
throw new ASException("分账单不存在");
}
QueryWrapper<ProfitSharingDetailEntity> detailWrapper = new QueryWrapper<>();
detailWrapper.eq("sharing_order_id", id);
List<ProfitSharingDetailEntity> details = profitSharingDetailDao.selectList(detailWrapper);
order.setDetails(details);
Map<String, Object> data = new HashMap<>();
data.put("order", order);
return R.dataSuccess(data);
}
/**
* 统计(参数 startDate, endDate, groupBy)
* 返回: { summary: {totalCount, totalAmount, successCount, successAmount}, groups: [{orgId, orgName, orgType, count, amount}] }
*/
@ApiOperation("分账统计")
@GetMapping("/stats")
public R stats(@RequestParam(value = "startDate", required = false) String startDate,
@RequestParam(value = "endDate", required = false) String endDate,
@RequestParam(value = "groupBy", required = false) String groupBy) {
QueryWrapper<ProfitSharingOrderEntity> wrapper = new QueryWrapper<>();
wrapper.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL);
if (StringUtils.isNotBlank(startDate)) {
wrapper.ge("created_at", startDate + " 00:00:00");
}
if (StringUtils.isNotBlank(endDate)) {
wrapper.le("created_at", endDate + " 23:59:59");
}
List<ProfitSharingOrderEntity> orders = profitSharingOrderDao.selectList(wrapper);
// 汇总
int totalCount = orders.size();
int totalAmount = 0;
int successCount = 0;
int successAmount = 0;
for (ProfitSharingOrderEntity order : orders) {
if (order.getTotalAmount() != null) {
totalAmount += order.getTotalAmount();
}
if (DayCardConstants.SETTLE_STATUS_SUCCESS.equals(order.getStatus())) {
successCount++;
if (order.getTotalAmount() != null) {
successAmount += order.getTotalAmount();
}
}
}
Map<String, Object> summary = new HashMap<>();
summary.put("totalCount", totalCount);
summary.put("totalAmount", totalAmount);
summary.put("successCount", successCount);
summary.put("successAmount", successAmount);
// 分组统计
List<Map<String, Object>> groups = new ArrayList<>();
if (StringUtils.isNotBlank(groupBy) && "org".equals(groupBy)) {
// 按组织分组:查询所有明细,按 orgId 聚合
Map<Long, Map<String, Object>> orgMap = new HashMap<>();
for (ProfitSharingOrderEntity order : orders) {
QueryWrapper<ProfitSharingDetailEntity> detailWrapper = new QueryWrapper<>();
detailWrapper.eq("sharing_order_id", order.getId());
List<ProfitSharingDetailEntity> details = profitSharingDetailDao.selectList(detailWrapper);
for (ProfitSharingDetailEntity detail : details) {
Long orgId = detail.getOrgId();
if (orgId == null) {
continue;
}
Map<String, Object> group = orgMap.get(orgId);
if (group == null) {
group = new HashMap<>();
group.put("orgId", orgId);
group.put("count", 0);
group.put("amount", 0);
group.put("orgRole", detail.getOrgRole());
// 查询组织名称
SysOrgEntity org = sysOrgDao.selectById(orgId);
if (org != null) {
group.put("orgName", org.getName());
group.put("orgType", org.getType());
}
orgMap.put(orgId, group);
}
group.put("count", (Integer) group.get("count") + 1);
if (detail.getAmount() != null) {
group.put("amount", (Integer) group.get("amount") + detail.getAmount());
}
}
}
groups.addAll(orgMap.values());
}
Map<String, Object> result = new HashMap<>();
result.put("summary", summary);
result.put("groups", groups);
return R.dataSuccess(result);
}
}

View File

@@ -0,0 +1,252 @@
package io.as.modules.daycard.controller;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.entity.ProfitSharingRuleEntity;
import io.as.modules.daycard.entity.ProfitSharingRuleItemEntity;
import io.as.modules.daycard.service.ProfitSharingRuleService;
import io.as.modules.sys.entity.SysOrgEntity;
import io.as.modules.sys.service.SysOrgService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 分润规则 Controller
*/
@Api(tags = "分润规则管理")
@RestController
@RequestMapping("/manage/profit-rule")
public class ProfitSharingRuleController {
@Autowired
private ProfitSharingRuleService profitSharingRuleService;
@Autowired
private SysOrgService sysOrgService;
/**
* 分页查询分润规则
*/
@ApiOperation("分页查询分润规则")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
PageUtils page = profitSharingRuleService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 分润规则详情(含 items 子表)
*/
@ApiOperation("分润规则详情")
@GetMapping("/info/{id}")
public R info(@PathVariable("id") Long id) {
ProfitSharingRuleEntity rule = profitSharingRuleService.getInfo(id);
if (rule == null) {
throw new ASException("分润规则不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("rule", rule);
return R.dataSuccess(data);
}
/**
* 新增分润规则
*/
@ApiOperation("新增分润规则")
@AuditAction(value = "PROFIT_RULE_SAVE", description = "新增分润规则")
@PostMapping("/save")
public R save(@RequestBody ProfitSharingRuleEntity rule) {
if (StringUtils.isBlank(rule.getRuleName())) {
throw new ASException("规则名称不能为空");
}
if (StringUtils.isBlank(rule.getScopeType())) {
throw new ASException("范围类型不能为空");
}
profitSharingRuleService.saveRule(rule);
Map<String, Object> data = new HashMap<>();
data.put("rule", rule);
return R.dataSuccess(data);
}
/**
* 修改分润规则
*/
@ApiOperation("修改分润规则")
@AuditAction(value = "PROFIT_RULE_UPDATE", description = "修改分润规则")
@PostMapping("/update")
public R update(@RequestBody ProfitSharingRuleEntity rule) {
if (rule.getId() == null) {
throw new ASException("id不能为空");
}
profitSharingRuleService.updateRule(rule);
return R.ok();
}
/**
* 删除分润规则(逻辑删除)
*/
@ApiOperation("删除分润规则")
@AuditAction(value = "PROFIT_RULE_DELETE", description = "删除分润规则")
@PostMapping("/delete")
public R delete(@RequestBody Map<String, Object> body) {
Long id = Long.parseLong(body.get("id").toString());
profitSharingRuleService.delete(id);
return R.ok();
}
/**
* 试算:根据门店和金额预览分润结果
*/
@ApiOperation("分润试算")
@AuditAction(value = "PROFIT_RULE_PREVIEW", description = "分润试算")
@PostMapping("/preview")
public R preview(@RequestBody Map<String, Object> body) {
Long storeOrgId = Long.parseLong(body.get("storeOrgId").toString());
Long amount = Long.parseLong(body.get("amount").toString());
// 1. 从 storeOrgId 查 sys_org 获取 mpath,解析出 channelOrgId 和 agentOrgId
SysOrgEntity storeOrg = sysOrgService.getById(storeOrgId);
if (storeOrg == null) {
throw new ASException("门店组织不存在");
}
String mpath = storeOrg.getMpath();
if (StringUtils.isBlank(mpath)) {
throw new ASException("门店组织 mpath 为空");
}
String[] segments = mpath.split("\\.");
// segments[0] = PLATFORM, segments[1] = CHANNEL, segments[n-1] = STORE
Long platformOrgId = null;
Long channelOrgId = null;
Long agentOrgId = null;
if (segments.length >= 1) {
platformOrgId = Long.parseLong(segments[0]);
}
if (segments.length >= 2) {
channelOrgId = Long.parseLong(segments[1]);
}
// segments.length >= 4 表示路径中有代理商(PLATFORM.CHANNEL.AGENT.STORE)
if (segments.length >= 4) {
agentOrgId = Long.parseLong(segments[segments.length - 2]);
}
// 2. 调用 matchRule 匹配规则
ProfitSharingRuleEntity rule = profitSharingRuleService.matchRule(storeOrgId, agentOrgId, channelOrgId);
if (rule == null) {
throw new ASException("未匹配到分润规则");
}
// 3. 按 items 计算各方分账金额
List<ProfitSharingRuleItemEntity> items = rule.getItems();
if (items == null || items.isEmpty()) {
throw new ASException("分润规则未配置明细");
}
// 按 sortOrder 排序
items.sort((a, b) -> {
int sa = a.getSortOrder() == null ? 0 : a.getSortOrder();
int sb = b.getSortOrder() == null ? 0 : b.getSortOrder();
return Integer.compare(sa, sb);
});
// orgRole -> orgId 映射
Map<String, Long> orgIdMap = new HashMap<>();
orgIdMap.put(DayCardConstants.ORG_ROLE_PLATFORM, platformOrgId);
orgIdMap.put(DayCardConstants.ORG_ROLE_CHANNEL, channelOrgId);
orgIdMap.put(DayCardConstants.ORG_ROLE_AGENT, agentOrgId);
orgIdMap.put(DayCardConstants.ORG_ROLE_STORE, storeOrgId);
// 找到最后一个 PLATFORM 项(尾差归平台)
int lastPlatformIndex = -1;
for (int i = 0; i < items.size(); i++) {
if (DayCardConstants.ORG_ROLE_PLATFORM.equals(items.get(i).getOrgRole())) {
lastPlatformIndex = i;
}
}
// 计算非平台方 RATIO 比例之和
BigDecimal nonPlatformTotalRatio = BigDecimal.ZERO;
// 计算各方分账金额
List<Map<String, Object>> details = new ArrayList<>();
long allocatedTotal = 0;
for (int i = 0; i < items.size(); i++) {
ProfitSharingRuleItemEntity item = items.get(i);
Map<String, Object> detail = new HashMap<>();
detail.put("orgRole", item.getOrgRole());
detail.put("shareType", item.getShareType());
detail.put("shareValue", item.getShareValue());
Long orgId = orgIdMap.get(item.getOrgRole());
detail.put("orgId", orgId);
// 查询组织名称
String orgName = null;
if (orgId != null) {
SysOrgEntity org = sysOrgService.getById(orgId);
if (org != null) {
orgName = org.getName();
}
}
detail.put("orgName", orgName);
long itemAmount;
if (i == lastPlatformIndex) {
// 最后一方 PLATFORM 用减法(尾差归平台)
itemAmount = amount - allocatedTotal;
} else if (DayCardConstants.SHARE_TYPE_FIXED.equals(item.getShareType())) {
// FIXED: shareValue 直接作为分
itemAmount = item.getShareValue().longValue();
allocatedTotal += itemAmount;
} else {
// RATIO: amount * shareValue / 100
BigDecimal ratioAmount = BigDecimal.valueOf(amount)
.multiply(item.getShareValue())
.divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN);
itemAmount = ratioAmount.longValue();
allocatedTotal += itemAmount;
}
// 统计非平台方 RATIO 比例之和
if (!DayCardConstants.ORG_ROLE_PLATFORM.equals(item.getOrgRole())
&& DayCardConstants.SHARE_TYPE_RATIO.equals(item.getShareType())
&& item.getShareValue() != null) {
nonPlatformTotalRatio = nonPlatformTotalRatio.add(item.getShareValue());
}
detail.put("amount", itemAmount);
details.add(detail);
}
// 5. warning 非 null 时表示超30%
String warning = null;
if (nonPlatformTotalRatio.compareTo(BigDecimal.valueOf(DayCardConstants.PROFIT_SHARE_LIMIT)) > 0) {
warning = "非平台方分润比例之和" + nonPlatformTotalRatio + "%超过上限" + DayCardConstants.PROFIT_SHARE_LIMIT + "%";
}
// 4. 返回结果
Map<String, Object> result = new HashMap<>();
result.put("ruleId", rule.getId());
result.put("ruleName", rule.getRuleName());
result.put("details", details);
result.put("nonPlatformTotalRatio", nonPlatformTotalRatio);
result.put("warning", warning);
Map<String, Object> data = new HashMap<>();
data.put("data", result);
return R.dataSuccess(data);
}
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardActivationCodeEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡设备激活码 DAO
*/
@Mapper
public interface DayCardActivationCodeDao extends BaseMapper<DayCardActivationCodeEntity> {
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardImportBatchEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡导入批次
*/
@Mapper
public interface DayCardImportBatchDao extends BaseMapper<DayCardImportBatchEntity> {
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardOrderEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡订单
*/
@Mapper
public interface DayCardOrderDao extends BaseMapper<DayCardOrderEntity> {
}

View File

@@ -0,0 +1,22 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardPoolEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 天卡池
*/
@Mapper
public interface DayCardPoolDao extends BaseMapper<DayCardPoolEntity> {
/**
* 使用 SELECT ... FOR UPDATE SKIP LOCKED 抢占一张未使用的天卡,
* 避免多线程/多实例并发下单时分配到同一张卡。
* <p>天卡池为通用池,不再按 partner 过滤</p>
*
* @return 被当前事务锁定的可用天卡行,无可用时返回 null
*/
DayCardPoolEntity selectOneForUpdateSkipLocked();
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardPriceConfigEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡定价策略 Dao
*/
@Mapper
public interface DayCardPriceConfigDao extends BaseMapper<DayCardPriceConfigEntity> {
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardProfitReceiverEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡分账接收方 Dao
*/
@Mapper
public interface DayCardProfitReceiverDao extends BaseMapper<DayCardProfitReceiverEntity> {
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardProfitRuleEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡分润规则 Dao
*/
@Mapper
public interface DayCardProfitRuleDao extends BaseMapper<DayCardProfitRuleEntity> {
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardRefundEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡退款单 Dao
*/
@Mapper
public interface DayCardRefundDao extends BaseMapper<DayCardRefundEntity> {
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardSettleOrderEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡分账单 Dao
*/
@Mapper
public interface DayCardSettleOrderDao extends BaseMapper<DayCardSettleOrderEntity> {
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardSettleRecordEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡分账记录 Dao
*/
@Mapper
public interface DayCardSettleRecordDao extends BaseMapper<DayCardSettleRecordEntity> {
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.DayCardSettleRuleEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 天卡分账规则 Dao
*/
@Mapper
public interface DayCardSettleRuleDao extends BaseMapper<DayCardSettleRuleEntity> {
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.OrgSettlementAccountEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 组织分账接收方 Dao
*/
@Mapper
public interface OrgSettlementAccountDao extends BaseMapper<OrgSettlementAccountEntity> {
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.ProfitSharingDetailEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 分账明细 Dao
*/
@Mapper
public interface ProfitSharingDetailDao extends BaseMapper<ProfitSharingDetailEntity> {
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.ProfitSharingOrderEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 分账单 Dao
*/
@Mapper
public interface ProfitSharingOrderDao extends BaseMapper<ProfitSharingOrderEntity> {
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.ProfitSharingRuleEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 分润规则 Dao
*/
@Mapper
public interface ProfitSharingRuleDao extends BaseMapper<ProfitSharingRuleEntity> {
}

View File

@@ -0,0 +1,12 @@
package io.as.modules.daycard.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.daycard.entity.ProfitSharingRuleItemEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 分润规则明细 Dao
*/
@Mapper
public interface ProfitSharingRuleItemDao extends BaseMapper<ProfitSharingRuleItemEntity> {
}

View File

@@ -0,0 +1,52 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 天卡设备激活码
* <p>管理后台按门店生成,App 端 init 时输入激活码完成设备注册绑定</p>
*/
@Data
@TableName("day_card_activation_code")
public class DayCardActivationCodeEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 激活码(全局唯一) */
private String code;
/** 渠道组织ID */
private Long channelOrgId;
/** 门店组织ID */
private Long storeOrgId;
/** 状态:ACTIVE/DISABLED */
private String status;
/** 最大可激活设备数(0=不限) */
private Integer maxDeviceCount;
/** 已激活设备数 */
private Integer activatedCount;
/** 备注 */
private String remark;
/** 删除标记:UNDEL/DEL */
private String deleteFlag;
/** 创建时间 */
private Date createdAt;
/** 更新时间 */
private Date updatedAt;
}

View File

@@ -0,0 +1,46 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 天卡导入批次
*/
@Data
@TableName("day_card_import_batch")
public class DayCardImportBatchEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 批次号 */
private String batchNo;
/** 合作商ID */
private Long partnerId;
/** 状态:PROCESSING/SUCCEEDED/PARTIAL_FAILED/FAILED */
private String status;
/** 总数 */
private Integer totalCount;
/** 成功数 */
private Integer successCount;
/** 失败数 */
private Integer failCount;
/** 结果报告URL */
private String resultReportUrl;
/** 备注 */
private String remark;
/** 创建人 */
private Long createdBy;
/** 删除标记 */
private String deleteFlag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
}

View File

@@ -0,0 +1,82 @@
package io.as.modules.daycard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 天卡订单
*/
@Data
@TableName("day_card_order")
public class DayCardOrderEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 订单号 */
private String orderNo;
/** 合作商ID */
private Long partnerId;
/** 设备ID */
private Long deviceId;
/** 门店组织ID */
private Long storeOrgId;
/** 渠道组织ID */
private Long channelOrgId;
/** 支付后写入的卡号 */
private String cardNo;
/** 订单状态:CREATED/PAYING/PAID/PAY_FAILED/CANCELLED */
private String status;
/** 订单金额 */
private BigDecimal amount;
/** 支付单号 */
private String payNo;
/** 支付金额 */
private BigDecimal payAmount;
/** 支付时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date payTime;
/** 订单过期时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date expireTime;
/** 取消时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date cancelTime;
/** 删除标记 */
private String deleteFlag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
/** 支付渠道 MOCK/WECHAT */
private String payChannel;
/** 微信预支付ID */
private String prepayId;
/** 微信扫码支付链接 */
private String codeUrl;
/** 设备SN(冗余,便于排查) */
private String deviceSn;
/** 设备MAC(冗余) */
private String mac;
/** 代理商组织ID */
private Long agentOrgId;
/** 组织链路快照(如 1.5.12.) */
private String orgPathSnapshot;
/** 微信支付订单号 */
private String channelOrderNo;
/** 合作商名称(非表字段) */
@TableField(exist = false)
private String partnerName;
/** 设备MAC地址(非表字段) */
@TableField(exist = false)
private String deviceMac;
}

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