Compare commits
10 Commits
90849e5267
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b9b1024c8 | ||
|
|
fffc623ab0 | ||
|
|
6a3b4fef1f | ||
|
|
e2300fc7d0 | ||
|
|
985b35cd90 | ||
|
|
56239450d4 | ||
|
|
d335c577d3 | ||
|
|
6fbc28d6f2 | ||
|
|
17a8c358a8 | ||
|
|
a7e567e9b4 |
177
llm/earnings-adjustments-and-withdrawal-reject.md
Normal file
177
llm/earnings-adjustments-and-withdrawal-reject.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Earnings Adjustments & Withdrawal Reject — Expected Behavior
|
||||
|
||||
This document defines the intended behavior for:
|
||||
|
||||
- Admin-created **earnings adjustments** (positive or negative earning lines)
|
||||
- Admin **withdrawal reject** (cancel a withdrawal request and release reserved earning lines)
|
||||
- **Authorization** rules (permission + group leader scope + cross-tenant isolation)
|
||||
|
||||
## Concepts
|
||||
|
||||
### Earnings Line
|
||||
An `earnings line` is an immutable money movement entry for a clerk. Amount can be positive or negative.
|
||||
|
||||
### Adjustment
|
||||
An `adjustment` is an admin-originated earnings line, designed to support future extensibility (many “reasons”, auditability, idempotency, async apply).
|
||||
|
||||
Key semantics:
|
||||
|
||||
- It **creates exactly one** earnings line when applied.
|
||||
- The created earnings line uses:
|
||||
- `earningType = ADJUSTMENT`
|
||||
- `sourceType = ADJUSTMENT`
|
||||
- `sourceId = adjustmentId`
|
||||
- `orderId = null`
|
||||
- `amount` can be positive or negative
|
||||
- `unlockTime = effectiveTime` (adjustments are effective at their “unlock” time)
|
||||
|
||||
### Withdrawal Reject
|
||||
Admin reject is a cancel operation that:
|
||||
|
||||
- marks the withdrawal request as canceled/rejected
|
||||
- releases reserved `withdrawing` earnings lines back to `available` / `frozen`
|
||||
|
||||
## Authorization Model (New Endpoints)
|
||||
|
||||
Authorization is **two-layer**:
|
||||
|
||||
1) **Action-level permission**: does the user have permission to call the endpoint?
|
||||
2) **Object-level scope**: can the user act on the target clerk / request?
|
||||
|
||||
### Permission Strings
|
||||
|
||||
- Create adjustment: `withdraw:adjustment:create`
|
||||
- Read/poll adjustment status: `withdraw:adjustment:read`
|
||||
- Reject withdrawal request: `withdraw:request:reject`
|
||||
|
||||
### Group Leader Scope
|
||||
|
||||
If the current user is **not** `superAdmin`, they can only act on clerks that belong to a group where:
|
||||
|
||||
- `clerk.groupId = group.id`
|
||||
- `group.sysUserId = currentUserId`
|
||||
|
||||
If this scope check fails, the endpoint returns **HTTP 403**.
|
||||
|
||||
### Super Admin Bypass
|
||||
|
||||
If `superAdmin == true`, the user bypasses permission checks and scope checks for these new endpoints.
|
||||
|
||||
### Cross-Tenant Isolation
|
||||
|
||||
All operations are tenant-scoped.
|
||||
|
||||
- If `X-Tenant` does not match the target entity’s `tenantId`, the API returns **HTTP 404** (do not leak existence across tenants).
|
||||
|
||||
## Admin Earnings Adjustments API
|
||||
|
||||
### Create Adjustment
|
||||
|
||||
`POST /admin/earnings/adjustments`
|
||||
|
||||
Headers:
|
||||
|
||||
- `Idempotency-Key: <uuid>` (required)
|
||||
- `X-Tenant: <tenantId>` (required)
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"clerkId": "clerk-id",
|
||||
"amount": "20.00",
|
||||
"reasonType": "MANUAL",
|
||||
"reasonDescription": "text",
|
||||
"effectiveTime": "2026-01-01T12:00:00" // optional
|
||||
}
|
||||
```
|
||||
|
||||
Validation rules:
|
||||
|
||||
- `Idempotency-Key` required
|
||||
- `tenantId` required
|
||||
- `clerkId` required
|
||||
- `amount` must be non-zero (positive = reward-like, negative = punishment-like)
|
||||
- `reasonType` required (currently hard-coded enum values, extend later)
|
||||
- `reasonDescription` required, non-blank
|
||||
|
||||
Idempotency behavior:
|
||||
|
||||
- Same `tenantId + Idempotency-Key` with the **same request body** returns the **same** `adjustmentId`.
|
||||
- Same `tenantId + Idempotency-Key` with a **different request body** returns **HTTP 409**.
|
||||
|
||||
Response behavior:
|
||||
|
||||
- Always returns **HTTP 202 Accepted** on success (request is “in-progress”).
|
||||
- Includes `Location: /admin/earnings/adjustments/idempotency/{Idempotency-Key}` for polling.
|
||||
|
||||
Response example:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 202,
|
||||
"message": "请求处理中",
|
||||
"data": {
|
||||
"adjustmentId": "adj-uuid",
|
||||
"idempotencyKey": "same-key",
|
||||
"status": "PROCESSING"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Poll Adjustment Status
|
||||
|
||||
`GET /admin/earnings/adjustments/idempotency/{key}`
|
||||
|
||||
Behavior:
|
||||
|
||||
- If not found in this tenant: **HTTP 404**
|
||||
- If found:
|
||||
- returns **HTTP 200**
|
||||
- `status` is one of:
|
||||
- `PROCESSING`: accepted but not yet applied
|
||||
- `APPLIED`: earnings line has been created
|
||||
- `FAILED`: apply failed (and should be visible for operator debugging)
|
||||
|
||||
Stress / eventual consistency note:
|
||||
|
||||
- Under load (DB latency / executor backlog), polling may stay in `PROCESSING` longer, but must not create duplicate earnings lines.
|
||||
|
||||
## Withdrawal Reject API
|
||||
|
||||
### Reject Withdrawal Request
|
||||
|
||||
`POST /admin/withdraw/requests/{id}/reject`
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{ "reason": "text (optional)" }
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- If request does not exist in this tenant: **HTTP 404**
|
||||
- If request is already canceled/rejected: return **HTTP 200** (idempotent)
|
||||
- If request is `success`: return **HTTP 400** (cannot reject a successful payout)
|
||||
- Otherwise:
|
||||
- request status transitions to `canceled` (or `rejected` depending on legacy naming)
|
||||
- all earnings lines with:
|
||||
- `withdrawalId = requestId`
|
||||
- `status = withdrawing`
|
||||
are released:
|
||||
- `withdrawalId` set to `null`
|
||||
- if `unlockTime > now` -> `status = frozen`
|
||||
- else -> `status = available`
|
||||
|
||||
## Stats: includeAdjustments toggle
|
||||
|
||||
The statistics endpoint supports a toggle `includeAdjustments`:
|
||||
|
||||
- when `includeAdjustments = false` (default): only order-derived earnings contribute
|
||||
- when `includeAdjustments = true`: adjustment earnings lines (`sourceType=ADJUSTMENT`) are included in the revenue sum
|
||||
|
||||
Time-window behavior:
|
||||
|
||||
- adjustment inclusion is based on `unlockTime` window (equivalent to `effectiveTime`)
|
||||
|
||||
359
llm/wechat-subsystem-test-matrix.md
Normal file
359
llm/wechat-subsystem-test-matrix.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# WeChat Subsystem — Characterization / Integration Test Matrix
|
||||
|
||||
This document is a **behavior pin** (characterization) test matrix for the current WeChat-related subsystem.
|
||||
The goal is to lock observable behavior (HTTP + DB + Redis + external calls) so later refactoring can be done safely.
|
||||
|
||||
Repo root: `/Volumes/main/code/yunpei/peipei-backend`
|
||||
|
||||
## Source Inventory (entry points)
|
||||
|
||||
### Controllers
|
||||
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOauthController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPlayController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCommonController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOrderInfoController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCouponController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkMediaController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxBlindBoxController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPkController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxGiftController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkCommodityController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCommodityController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxLevelController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxShopController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkWagesController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPersonnelGroupInfoController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPlayOrderRankingController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawPayeeController.java`
|
||||
|
||||
### Services / Cross-cutting
|
||||
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxOauthService.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxCustomMpService.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxTokenService.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxCustomUserService.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxGiftOrderService.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxBlindBoxOrderService.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/MediaUploadService.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/common/security/filter/JwtAuthenticationTokenFilter.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/common/aspect/CustomUserLoginAspect.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/common/aspect/ClerkUserLoginAspect.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/NotificationSender.java`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/MockNotificationSender.java`
|
||||
|
||||
### DB schema touchpoints
|
||||
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/resources/db/migration/V1__init_schema.sql`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/resources/db/migration/V17__create_media.sql`
|
||||
|
||||
Key fields to pin:
|
||||
|
||||
- `sys_tenant`: `tenant_key`, `app_id`, `secret`, `mch_id`, `mch_key`, `*_template_id`, `profitsharing_rate`
|
||||
- `play_custom_user_info`: `openid`, `unionid`, `wei_chat_code`, `token`
|
||||
- `play_clerk_user_info`: `openid` (NOT NULL), `wei_chat_code`, `token`, `online_state`, `onboarding_state`, `listing_state`, `clerk_state`
|
||||
- `play_order_info`: `order_type`, `pay_method`, `pay_state`, `place_type`, `reward_type`, `wei_chat_code`, `profit_sharing_amount`
|
||||
- `play_media` / `play_clerk_media_asset`: `status`, `usage`, `review_state`, `deleted`, `order_index` + unique constraint `uk_clerk_usage_media`
|
||||
|
||||
## Test Harness Guidelines
|
||||
|
||||
### Required runtime surfaces to observe
|
||||
|
||||
1) **HTTP**: status code + response schema (normalized) for every `/wx/**` route.
|
||||
2) **DB**: before/after snapshots of rows touched by the route (focus tables above).
|
||||
3) **Redis**: keys under:
|
||||
- `TENANT_INFO:*`
|
||||
- `login_codes:*`
|
||||
- PK keys under `PkRedisKeyConstants` (if enabled)
|
||||
4) **External calls**: record interactions with:
|
||||
- `WxMpService` / WeChat MP template message service
|
||||
- `WxPayService` (unified order + profitsharing)
|
||||
- `IOssFileService`
|
||||
- `SmsUtils`
|
||||
- background tasks like `OverdueOrderHandlerTask`
|
||||
|
||||
### Profiles
|
||||
|
||||
- Prefer running integration tests under `apitest` profile (seeded DB + mock notifier).
|
||||
- For tests that must hit real async behavior, use deterministic executors in test config (or replace `ThreadPoolTaskExecutor` with inline executor via `@MockBean`).
|
||||
|
||||
### Normalization rules (avoid flaky tests)
|
||||
|
||||
- Normalize dynamic fields: timestamps, random IDs (UUID), and generated nonces.
|
||||
- For money: always compare as `BigDecimal` with explicit scale/rounding.
|
||||
- For lists: assert stable ordering only when the endpoint guarantees ordering.
|
||||
|
||||
---
|
||||
|
||||
## 1) Gateway / Tenant / AuthN (Filter + AOP)
|
||||
|
||||
| ID | Surface | Route / Component | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| GW-001 | HTTP | `JwtAuthenticationTokenFilter` | `/wx/**` without `tenantkey` header on a login-required route | none | none | response is error (pin: code/body shape + status) |
|
||||
| GW-002 | HTTP | `JwtAuthenticationTokenFilter` | `/wx/**` without `tenantkey` on a no-login whitelist route | none | none | response is error (pin: JSON body written by filter) |
|
||||
| GW-003 | HTTP | `JwtAuthenticationTokenFilter` | `/wx/pay/jsCallback` bypasses auth and does not require `tenantkey` | none | none | handler executes and returns success string |
|
||||
| GW-004 | HTTP/Redis | filter `getTenantId` | with `tenantkey` only (no token) | tenant exists | `ISysTenantService` real or stub | `SecurityUtils.getTenantId()` set; request proceeds |
|
||||
| GW-005 | HTTP/Redis | filter `getTenantId` | with clerk token | seed clerk with token + tenantId | none | tenantId resolved from DB; `TENANT_INFO:{userId}` read does not break flow |
|
||||
| GW-006 | HTTP/Redis | filter `getTenantId` | with custom token | seed customer with token + tenantId | none | tenantId resolved from DB |
|
||||
| AOP-001 | HTTP | `@CustomUserLogin` endpoints | missing `customusertoken` header | none | none | 401 `token为空` |
|
||||
| AOP-002 | HTTP | `@ClerkUserLogin` endpoints | missing `clerkusertoken` header | none | none | 401 `token为空` |
|
||||
| AOP-003 | HTTP | `CustomUserLoginAspect` | invalid token format/signature | none | none | 401 `获取用户信息异常` |
|
||||
| AOP-004 | HTTP | `CustomUserLoginAspect` | token ok but DB token mismatch | seed user with different token | none | 401 `token异常` |
|
||||
| AOP-005 | HTTP | `ClerkUserLoginAspect` | `ensureClerkSessionIsValid` rejects | seed clerk state invalid | none | 401 message matches current `CustomException` message |
|
||||
| TOK-001 | unit/contract | `WxTokenService` | token generated and parsed (with/without `Bearer `) | config secret + expireTime | none | same userId extracted |
|
||||
| TOK-002 | unit/contract | `WxTokenService` | expired token fails | short expireTime | none | parsing throws; upstream maps to 401 |
|
||||
|
||||
---
|
||||
|
||||
## 2) WeChat OAuth (WxOauthController + WxOauthService)
|
||||
|
||||
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| OAUTH-001 | HTTP | `POST /wx/oauth2/getConfigAddress` | `url` omitted -> uses default callback | valid tenant | mock `WxMpService` signature | signature returned; default URL pinned |
|
||||
| OAUTH-002 | HTTP | `POST /wx/oauth2/getConfigAddress` | `url` provided -> overrides default | valid tenant | mock signature | uses provided URL |
|
||||
| OAUTH-003 | HTTP | `POST /wx/oauth2/getClerkLoginAddress` | returns auth URL with scope `snsapi_userinfo` | valid tenant | mock OAuth2 service build URL | response contains expected scope |
|
||||
| OAUTH-004 | HTTP | `POST /wx/oauth2/getCustomLoginAddress` | same as clerk | valid tenant | mock OAuth2 service | response contains expected scope |
|
||||
| OAUTH-005 | HTTP/DB/Redis | `POST /wx/oauth2/custom/login` | success -> returns token payload + persists token | seed tenant + seeded customer openid | mock `WxMpService.getOAuth2Service().getAccessToken/getUserInfo` | response includes `tokenValue/tokenName/loginDate`; DB token updated; Redis `TENANT_INFO:{id}` set |
|
||||
| OAUTH-006 | HTTP | `POST /wx/oauth2/custom/login` | upstream WeChat oauth fails -> unauthorized | none | mock `WxMpService.getOAuth2Service().getAccessToken` to throw | response `code=401` (and `success=false`) |
|
||||
| OAUTH-007 | HTTP/DB/Redis | `POST /wx/oauth2/clerk/login` | success -> token persisted + tenant cached; `pcData` present | seed a clerk with `sysUserId=""` to avoid PC login dependency | mock `WxMpService.getOAuth2Service().getAccessToken/getUserInfo` | response includes `pcData.token/role` (empty strings); DB token updated; Redis `TENANT_INFO:{id}` set |
|
||||
| OAUTH-008 | HTTP/DB | `GET /wx/oauth2/custom/logout` | token invalidated | seed user + token | none | DB token set to `empty`; subsequent access 401 |
|
||||
| OAUTH-009 | service/DB | `WxOauthService.customUserLogin` | first login creates new user with registrationTime | empty DB | mock MP OAuth2 | row inserted with expected fields |
|
||||
| OAUTH-010 | service/DB | `WxOauthService.clerkUserLogin` | deleted clerk restored | seed clerk deleted=true | mock MP OAuth2 | deleted=false; default states filled; token/online reset |
|
||||
| OAUTH-011 | HTTP | `GET /wx/oauth2/checkSubscribe` | returns boolean subscribe state | logged-in customer | mock `WxMpService.getUserService().userInfo` | response `data` is `true/false` |
|
||||
|
||||
---
|
||||
|
||||
## 3) WeChat Pay Recharge + Callback + Profit Sharing (WxPlayController)
|
||||
|
||||
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| PAY-001 | HTTP | `GET /wx/pay/custom/getCustomPaymentAmount` | money empty -> 500 | logged-in customer | none | error message pinned |
|
||||
| PAY-002 | HTTP | `GET /wx/pay/custom/getCustomPaymentAmount` | money valid -> BigDecimal returned | logged-in customer | none | response number pinned (scale/rounding) |
|
||||
| PAY-003 | HTTP/DB | `GET /wx/pay/custom/createOrder` | money < 1 -> 500 | logged-in customer | none | error message pinned |
|
||||
| PAY-004 | HTTP/DB | `GET /wx/pay/custom/createOrder` | creates recharge order + returns JSAPI pay params | logged-in customer + tenant with mch config | mock `WxPayService.unifiedOrder` and `SignUtils` inputs via capturing request | order row created (type/pay_state); response has required fields; unifiedOrder request fields pinned |
|
||||
| PAY-005 | HTTP | `GET /wx/pay/custom/createOrder` | subscribe check fails -> error | customer openid + tenant | mock `WxCustomMpService.checkSubscribeThrowsExp` to throw | HTTP error pinned |
|
||||
| PAY-006 | HTTP/DB | `POST/GET /wx/pay/jsCallback` | invalid XML -> still returns success string, no DB changes | existing DB | none | response equals success; no order updates |
|
||||
| PAY-007 | HTTP/DB | `/wx/pay/jsCallback` | unknown out_trade_no -> no changes | none | none | no DB changes |
|
||||
| PAY-008 | HTTP/DB | `/wx/pay/jsCallback` | order_type!=0 OR pay_state!=0 -> no reprocessing | seed paid/non-recharge order | none | no balance change; no state change |
|
||||
| PAY-009 | HTTP/DB/Redis | `/wx/pay/jsCallback` | happy path updates pay_state and balance | seed recharge order pay_state=0 + tenant attach | mock `customAccountBalanceRecharge` if needed or assert real side-effects | pay_state becomes `1`; balance updated; template message called |
|
||||
| PAY-010 | HTTP/DB | `/wx/pay/jsCallback` | replay same callback twice is idempotent | seed recharge order | none | balance does not double-add; profit_sharing_amount not duplicated |
|
||||
| PAY-011 | HTTP/DB | profitSharing | profitsharing_rate<=0 -> no call and no write | tenant rate=0 | mock WxPay profitSharing service | no API call; no DB update |
|
||||
| PAY-012 | HTTP/DB | profitSharing | rate>0 and computed amount=0 -> no call | tiny amount | mock | no call |
|
||||
| PAY-013 | HTTP/DB | profitSharing | rate>0 -> writes profit_sharing_amount | tenant rate set | mock profitSharing result | DB field set to expected BigDecimal |
|
||||
|
||||
---
|
||||
|
||||
## 4) WeChat MP Notifications (WxCustomMpService)
|
||||
|
||||
| ID | Surface | Component | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| MP-001 | unit/contract | `proxyWxMpService` | missing tenantId -> CustomException | tenantId unset | none | exception message pinned |
|
||||
| MP-002 | unit/contract | `getWxPay` | tenant missing mch_id -> CustomException | tenant has empty mchId | none | message pinned |
|
||||
| MP-003 | integration | `sendCreateOrderMessage` | template data fields mapping | tenant has templateId + tenantKey | mock template msg service | `short_thing5` label resolved; URL pinned |
|
||||
| MP-004 | integration | `sendCreateOrderMessageBatch` | filters offboarded/delisted clerks | clerk list mix | deterministic executor + mock template | only eligible clerks called |
|
||||
| MP-005 | integration | `sendBalanceMessage` | sends recharge success template | order + tenant + customer | mock template | data keys pinned |
|
||||
| MP-006 | integration | `sendOrderFinishMessage` | only placeType "1"/"2" triggers | orders with other placeType | mock template | no calls when not matched |
|
||||
| MP-007 | integration | async wrappers | exceptions inside async do not bubble | throw in underlying send | deterministic executor | caller does not fail |
|
||||
| MP-008 | integration | subscribe checks | subscribe=false -> ServiceException message pinned | mock `WxMpUser.subscribe=false` | mock user service | message pinned |
|
||||
|
||||
---
|
||||
|
||||
## 5) Common WeChat Tools (WxCommonController + WxFileUtils)
|
||||
|
||||
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| COM-001 | HTTP | `GET /wx/common/area/tree` | returns area tree | tenantKey only | mock area service or real | response schema pinned |
|
||||
| COM-002 | HTTP | `GET /wx/common/setting/info` | returns global UI config (NOT tenant-scoped) | call with two different `X-Tenant` values | real service | both responses share same `data.id` |
|
||||
| COM-003 | HTTP | `POST /wx/common/file/upload` | uploads to OSS returns URL | multipart file | mock `IOssFileService.upload` | returned URL pinned |
|
||||
| COM-004 | HTTP | `GET /wx/common/audio/upload` | mediaId empty -> error | none | none | error message pinned |
|
||||
| COM-005 | HTTP | `GET /wx/common/audio/upload` | successful path uploads mp3 | provide accessToken + temp audio stream | mock `WxAccessTokenService`, stub `WxFileUtils.getTemporaryMaterial` (via wrapper or test seam), mock OSS | returns URL; temp files cleaned |
|
||||
| COM-006 | unit/contract | `WxFileUtils.audioConvert2Mp3` | invalid source -> throws | empty file | none | error message pinned |
|
||||
|
||||
---
|
||||
|
||||
## 6) Customer (WxCustomController) — Orders, Gifts, Complaints, Follow, Leave Msg
|
||||
|
||||
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| CUS-001 | HTTP | `GET /wx/custom/queryClerkDetailedById` | not logged in still works | tenantKey only | none | response pinned (no crash) |
|
||||
| CUS-002 | HTTP/DB | `GET /wx/custom/queryById` | returns clerkState=1 when openid matches clerk | seed custom+clerk share openid | none | response `clerkState` pinned |
|
||||
| CUS-003 | HTTP/DB | `POST /wx/custom/updateHideLevelState` | ignores client id and uses session id | logged-in custom | none | DB update on session row only |
|
||||
| CUS-004 | HTTP/DB | `POST /wx/custom/order/reward` | creates completed reward order | logged-in custom + balance | none | order row fields pinned; ledger/balance delta pinned |
|
||||
| CUS-005 | HTTP/DB | `POST /wx/custom/order/gift` | calls WxGiftOrderService and increments gift counts | logged-in custom + gift seeded | none | order created; both gift counters incremented |
|
||||
| CUS-006 | HTTP/DB | `POST /wx/custom/order/commodity` | creates specified order pending | logged-in custom + clerk + commodity | mock pricing if needed | order fields pinned |
|
||||
| CUS-007 | HTTP/DB | `POST /wx/custom/order/random` | success triggers notification + overdue task | logged-in custom + clerks eligible | mock `WxCustomMpService`, mock `OverdueOrderHandlerTask` | expected calls + order fields pinned |
|
||||
| CUS-008 | HTTP/DB | `POST /wx/custom/order/random` | insufficient balance fails and no order created | set balance=0 | none | error code pinned; no DB insert |
|
||||
| CUS-009 | HTTP | `POST /wx/custom/order/queryByPage` | only returns self purchaserBy | seed orders for multiple users | none | result contains only self |
|
||||
| CUS-010 | HTTP/DB | `GET /wx/custom/order/end` | state transition invoked | seed order | none | order status moved (pin) |
|
||||
| CUS-011 | HTTP/DB | `POST /wx/custom/order/cancellation` | pins cancellation refund record (images ignored) | seed pending/accepted order + send non-empty images | none | order canceled; refund record has `refundReason`; `images` stays null; order `refundReason` stays null |
|
||||
| CUS-012 | HTTP/DB | `POST /wx/custom/order/evaluate/add` | non-purchaser cannot evaluate | order purchaser different | none | error message pinned |
|
||||
| CUS-013 | HTTP | `GET /wx/custom/order/evaluate/queryByOrderId` | not evaluated -> error | seed order no eval | none | `当前订单未评价` |
|
||||
| CUS-014 | HTTP/DB | `POST /wx/custom/order/complaint/add` | non-purchaser cannot complain | order purchaser different | none | error message pinned |
|
||||
| CUS-015 | HTTP/DB | `POST /wx/custom/leave/add` | creates leave msg; response message pinned | logged-in custom | none | DB insert; response message currently `"取消成功"` |
|
||||
| CUS-016 | HTTP | `GET /wx/custom/leave/queryPermission` | permission true/false schema pinned | set conditions | none | response JSON has `permission` boolean and `msg` |
|
||||
| CUS-017 | HTTP/DB | `POST /wx/custom/followState/update` | idempotency and correctness | seed follow relation | none | follow state pinned |
|
||||
| CUS-018 | HTTP | `POST /wx/custom/follow/queryByPage` | paging/filters pinned | seed relations | none | page schema pinned |
|
||||
|
||||
---
|
||||
|
||||
## 7) Clerk (WxClerkController) — Apply, Profile Review, Order Ops, Privacy Rules
|
||||
|
||||
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| CLK-001 | HTTP | `POST /wx/clerk/user/queryPerformanceInfo` | date normalize behavior pinned | seed orders | none | output stable for same input |
|
||||
| CLK-002 | HTTP | `GET /wx/clerk/user/queryLevelInfo` | hardcoded `levelAndRanking` list pinned | logged-in clerk | none | list size/content pinned |
|
||||
| CLK-003 | HTTP/Redis | `POST /wx/clerk/user/sendCode` | writes redis key with TTL and returns code | logged-in clerk | none | redis key format + TTL; response contains code |
|
||||
| CLK-004 | HTTP/Redis/DB | `POST /wx/clerk/user/bindCode` | wrong code -> error | seed redis code | none | `验证码错误` |
|
||||
| CLK-005 | HTTP/Redis/DB | `POST /wx/clerk/user/bindCode` | success updates phone and clears redis | seed redis code | none | DB phone updated; redis deleted |
|
||||
| CLK-006 | HTTP/DB | `POST /wx/clerk/user/add` | already clerk -> error | seed clerkState=1 | none | message pinned |
|
||||
| CLK-007 | HTTP/DB | `POST /wx/clerk/user/add` | already has pending review -> error | seed reviewState=0 | none | message pinned |
|
||||
| CLK-008 | HTTP | `POST /wx/clerk/user/add` | subscribe required | mock subscribe=false | mock `WxCustomMpService.checkSubscribeThrowsExp` | error message pinned |
|
||||
| CLK-009 | HTTP/DB | `POST /wx/clerk/user/updateNickname` | creates data review row with correct type and content | logged-in clerk | none | DB insert pinned |
|
||||
| CLK-010 | HTTP/DB | `POST /wx/clerk/user/updateAlbum` | empty album -> error | logged-in clerk | none | `最少上传一张照片` |
|
||||
| CLK-011 | HTTP/DB | `POST /wx/clerk/user/updateAlbum` | invalid new media -> error | seed play_media with mismatched owner/tenant/status | none | error message pinned |
|
||||
| CLK-012 | HTTP/DB | `POST /wx/clerk/user/updateAlbum` | legacy IDs not found -> should not fail (current behavior) | album includes missing IDs | none | request succeeds; review content contains legacy strings |
|
||||
| CLK-013 | HTTP/DB | `GET /wx/clerk/order/queryById` | privacy: non-owner clears weiChatCode | seed order acceptBy other clerk | none | weiChatCode empty |
|
||||
| CLK-014 | HTTP/DB | `GET /wx/clerk/order/queryById` | canceled order clears weiChatCode | seed orderStatus=4 | none | weiChatCode empty |
|
||||
| CLK-015 | HTTP | `GET /wx/clerk/order/accept` | subscribe required | mock subscribe=false | mock `WxCustomMpService` | fails |
|
||||
| CLK-016 | HTTP/DB | `GET /wx/clerk/order/start` | state transition pinned | seed order | none | state updated |
|
||||
| CLK-017 | HTTP/DB | `POST /wx/clerk/order/complete` | sysUserId missing -> error | seed clerk no sysUserId | none | message pinned |
|
||||
| CLK-018 | HTTP/DB | `POST /wx/clerk/order/complete` | permission resolution pinned (admin vs group leader) | seed sysUser mapping | none | correct operatorType chosen or error |
|
||||
| CLK-019 | HTTP/DB | `POST /wx/clerk/order/cancellation` | cancellation state pinned | seed order | none | status/cancel fields updated |
|
||||
| CLK-020 | HTTP | `POST /wx/clerk/user/queryEvaluateByPage` | forces hidden=VISIBLE | seed hidden evaluations | none | response excludes hidden |
|
||||
|
||||
---
|
||||
|
||||
## 8) Orders (WxOrderInfoController) — Continuation + Random Order Masking
|
||||
|
||||
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| ORD-001 | HTTP/DB | `POST /wx/order/clerk/continue` | non-owner cannot continue | order acceptBy != clerk | none | message pinned |
|
||||
| ORD-002 | HTTP/DB | `POST /wx/order/clerk/continue` | second continuation blocked | seed continue record | none | message pinned |
|
||||
| ORD-003 | HTTP | `GET /wx/order/clerk/selectRandomOrderById` | masking for non-owner pinned | seed random order accepted by other | none | fields blanked as implemented |
|
||||
| ORD-004 | HTTP/DB | `POST /wx/order/custom/updateReviewState` | reviewedState != 0 -> error | seed reviewedState=1 | none | `续单已处理` |
|
||||
| ORD-005 | HTTP/DB | `POST /wx/order/custom/continueListByPage` | customId forced to session | seed multiple users | none | only self results |
|
||||
|
||||
---
|
||||
|
||||
## 9) Coupons (WxCouponController)
|
||||
|
||||
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| CP-001 | HTTP | `GET /wx/coupon/custom/obtainCoupon` | id empty -> error message pinned | logged-in custom | none | error message pinned |
|
||||
| CP-002 | HTTP/DB | obtain coupon | not eligible -> returns reason | seed coupon restrictions | none | response contains reason |
|
||||
| CP-003 | HTTP/DB | obtain coupon | eligible -> claim succeeds | seed coupon inventory | none | coupon_details inserted; response success |
|
||||
| CP-004 | HTTP | query all | whitelist hides coupons from non-whitelisted | seed coupon whitelist | none | coupon not present |
|
||||
| CP-005 | HTTP | query by order | clerkId+levelId both empty -> error | none | none | message pinned |
|
||||
| CP-006 | HTTP/DB | query by order | unavailable coupon includes reasonForUnavailableUse | seed coupon restrictions | none | available=0 and reason set |
|
||||
| CP-007 | HTTP/DB | query by order | exceptions inside loop are swallowed (current behavior) | seed one broken coupon | none | endpoint still returns 200 with remaining coupons |
|
||||
|
||||
---
|
||||
|
||||
## 10) Media (WxClerkMediaController + MediaUploadService)
|
||||
|
||||
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| MED-001 | HTTP | `POST /wx/clerk/media/upload` | missing file -> error | logged-in clerk | none | message pinned |
|
||||
| MED-002 | HTTP/DB | upload image | creates `play_media` + `play_clerk_media_asset` | image multipart | mock OSS | DB rows inserted; kind=image; owner=clerk |
|
||||
| MED-003 | HTTP/DB | upload video too large | exceeds 30MB -> error | video > limit | none | error message pinned |
|
||||
| MED-004 | HTTP/DB | upload video too long | duration>45s -> error | long video | none | message pinned |
|
||||
| MED-005 | HTTP/DB | `PUT /wx/clerk/media/order` | distinct mediaIds and ordering pinned | seed assets | none | order_index updates pinned; duplicates removed |
|
||||
| MED-006 | HTTP/DB | `DELETE /wx/clerk/media/{id}` | marks `review_state=rejected` and sets `play_media.status=rejected` (asset `deleted` stays `0` currently) | seed media + asset | none | asset `review_state` becomes rejected; `play_media.status` becomes rejected; `asset.deleted` remains `0` |
|
||||
| MED-007 | HTTP | `GET /wx/clerk/media/list` | returns only draft/pending/rejected | seed assets states | none | filtering pinned |
|
||||
| MED-008 | HTTP | `GET /wx/clerk/media/approved` | returns only approved | seed assets | none | filtering pinned |
|
||||
| MED-009 | DB constraint | `uk_clerk_usage_media` | duplicate submit behavior pinned | seed duplicate row | none | error or ignore (pin current) |
|
||||
|
||||
---
|
||||
|
||||
## 11) Blind Box (WxBlindBoxController + WxBlindBoxOrderService)
|
||||
|
||||
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| BB-001 | HTTP | `GET /wx/blind-box/config/list` | not logged in -> error | none | none | message pinned |
|
||||
| BB-002 | HTTP | list configs | only active configs for tenant | seed configs | none | list content pinned |
|
||||
| BB-003 | HTTP/DB | `POST /wx/blind-box/order/purchase` | tenant mismatch -> “not found” (TenantLine current behavior) | config tenant != user tenant | none | message pinned (`盲盒不存在`) |
|
||||
| BB-004 | HTTP/DB | purchase | creates completed order + reward | seed config + balance | none | order type pinned; reward row created |
|
||||
| BB-005 | HTTP/DB | `POST /wx/blind-box/reward/{id}/dispatch` | reward not found -> error | none | none | message pinned |
|
||||
| BB-006 | HTTP/DB | dispatch | status transition pinned | seed reward | none | status updated; response view pinned |
|
||||
| BB-007 | HTTP | `GET /wx/blind-box/reward/list` | status filter pinned | seed rewards | none | filter results pinned |
|
||||
| BB-008 | HTTP/DB | `POST /wx/blind-box/order/purchase` | insufficient balance -> error and no new order | customer balance < config price | none | message pinned; no new order inserted |
|
||||
|
||||
---
|
||||
|
||||
## 12) PK (WxPkController)
|
||||
|
||||
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| PK-001 | HTTP | `GET /wx/pk/clerk/live` | clerkId missing -> error | none | none | message pinned |
|
||||
| PK-002 | HTTP | live | no pk -> inactive dto | seed none | none | returns inactive dto |
|
||||
| PK-003 | HTTP | live | pk exists but status!=IN_PROGRESS -> inactive | seed pk | none | inactive |
|
||||
| PK-004 | HTTP/Redis | upcoming | tenant missing -> error | no tenant context | none | message pinned |
|
||||
| PK-005 | HTTP/Redis | upcoming | redis hit/miss produces stable behavior | seed redis keys | none | response pinned |
|
||||
| PK-006 | HTTP | schedule/history | limit/page normalization pinned | none | none | safeLimit behavior pinned |
|
||||
|
||||
---
|
||||
|
||||
## 13) Shop + Articles + Misc
|
||||
|
||||
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| SHOP-001 | HTTP | `GET /wx/shop/custom/getShopHomeCarouseInfo` | returns carousel list | seed carousel | none | mapping pinned |
|
||||
| SHOP-002 | HTTP/DB | `GET /wx/shop/clerk/readShopArticleInfo` | visitsNumber increments | seed article | none | visitsNumber +1 persisted |
|
||||
| ART-001 | HTTP/DB | `POST /wx/article/clerk/add` | creates article with clerkId from session | logged-in clerk | none | DB insert pinned |
|
||||
| ART-002 | HTTP/DB | `GET /wx/article/clerk/deleteById` | deletes clerk article + custom article links | seed both | none | rows removed/soft-deleted pinned |
|
||||
| ART-003 | HTTP/DB | `POST /wx/article/custom/updateGreedState` | pins current toggle behavior (not strictly idempotent) | seed article | none | at least one row flips to `endorseState=0` after toggle |
|
||||
| ART-004 | HTTP/DB | `POST /wx/article/custom/updateFollowState` | same for follow | seed article | none | record updated |
|
||||
|
||||
---
|
||||
|
||||
## 14) Wages + Withdraw (WxClerkWagesController + WxWithdrawController)
|
||||
|
||||
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| WAGE-001 | HTTP/DB | `GET /wx/wages/clerk/queryUnsettledWages` | sums orders correctly | seed settlement_state=0 orders | none | totals pinned |
|
||||
| WAGE-002 | HTTP | `GET /wx/wages/clerk/queryCurrentPeriodWages` | missing wages row returns constructed zeros | no wages row | none | response has zeros and dates set |
|
||||
| WAGE-003 | HTTP | `POST /wx/wages/clerk/queryHistoricalWages` | current hard-coded page meta pinned | seed some rows | none | total=5,size=10,pages=1 pinned |
|
||||
| WD-001 | HTTP | `GET /wx/withdraw/balance` | returns available/pending/nextUnlock | seed earnings lines | none | values pinned |
|
||||
| WD-002 | HTTP | `GET /wx/withdraw/earnings` | time parsing supports multiple formats | seed earnings | none | filter correctness pinned |
|
||||
| WD-003 | HTTP/DB | `POST /wx/withdraw/requests` | amount<=0 error | none | none | message pinned |
|
||||
| WD-004 | HTTP/DB | create request | creates withdrawal request and reserves lines | seed available lines | none | statuses pinned |
|
||||
| WD-005 | HTTP | request logs | non-owner forbidden | seed request other clerk | none | `无权查看` |
|
||||
| PAYEE-001 | HTTP | `GET /wx/withdraw/payee` | no profile returns null data (current behavior) | none | none | response pinned |
|
||||
| PAYEE-002 | HTTP/DB | `POST /wx/withdraw/payee` | missing qrCodeUrl -> error | none | none | message pinned |
|
||||
| PAYEE-003 | HTTP/DB | upsert defaulting | defaults channel/displayName | seed clerk | none | stored defaults pinned |
|
||||
| PAYEE-004 | HTTP/DB | confirm requires qrCodeUrl | no profile | none | error message pinned |
|
||||
|
||||
---
|
||||
|
||||
## Coverage Checklist (for “done”)
|
||||
|
||||
- Every `@RequestMapping("/wx...")` route has at least:
|
||||
- 1 happy-path integration test
|
||||
- 1 auth/tenant gating test (where applicable)
|
||||
- 1 validation failure test (where applicable)
|
||||
- 1 DB side-effect snapshot assertion for routes that mutate state
|
||||
- WeChat Pay callback has:
|
||||
- replay/idempotency tests
|
||||
- malformed XML test
|
||||
- missing attach test
|
||||
- Media pipeline has:
|
||||
- type detection + size/duration limits pinned
|
||||
- DB uniqueness behavior pinned
|
||||
|
||||
## Test Case ID Naming Convention (important)
|
||||
|
||||
- In this doc, case IDs are written like `OAUTH-007`.
|
||||
- In Java test method names, we normalize them as `OAUTH_007` because `-` is not valid in Java identifiers.
|
||||
|
||||
## Implemented Coverage (current)
|
||||
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxOauthControllerApiTest.java`: `OAUTH-001..008`, `OAUTH-011`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxOauthServiceTest.java`: `OAUTH-009`, `OAUTH-010`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxPayControllerApiTest.java`: `PAY-001..011`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxAuthAspectApiTest.java`: `AOP-001..005`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxTokenServiceTest.java`: `TOK-001..002`
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerEndpointsApiTest.java`: `MED-005..008` (note: `MED-006` currently keeps `asset.deleted=0`)
|
||||
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxCommonControllerAudioUploadApiTest.java`: covers audio upload behavior (see `MED-*` section for alignment)
|
||||
@@ -25,6 +25,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
<!-- Flyway -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
@@ -159,6 +163,11 @@
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-inline</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-test</artifactId>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.starry.admin.common.apitest;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.starry.admin.modules.clerk.mapper.PlayClerkUserInfoMapper;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkCommodityEntity;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.module.enums.ClerkRoleStatus;
|
||||
import com.starry.admin.modules.clerk.module.enums.ListingStatus;
|
||||
import com.starry.admin.modules.clerk.module.enums.OnboardingStatus;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
@@ -15,6 +19,7 @@ import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
|
||||
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
|
||||
import com.starry.admin.modules.shop.mapper.PlayClerkGiftInfoMapper;
|
||||
import com.starry.admin.modules.shop.mapper.PlayCommodityInfoMapper;
|
||||
import com.starry.admin.modules.shop.module.entity.PlayClerkGiftInfoEntity;
|
||||
import com.starry.admin.modules.shop.module.entity.PlayCommodityAndLevelInfoEntity;
|
||||
import com.starry.admin.modules.shop.module.entity.PlayCommodityInfoEntity;
|
||||
@@ -23,6 +28,9 @@ import com.starry.admin.modules.shop.service.IPlayClerkGiftInfoService;
|
||||
import com.starry.admin.modules.shop.service.IPlayCommodityAndLevelInfoService;
|
||||
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
|
||||
import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
|
||||
import com.starry.admin.modules.system.mapper.SysMenuMapper;
|
||||
import com.starry.admin.modules.system.mapper.SysUserMapper;
|
||||
import com.starry.admin.modules.system.module.entity.SysMenuEntity;
|
||||
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
|
||||
import com.starry.admin.modules.system.module.entity.SysTenantPackageEntity;
|
||||
import com.starry.admin.modules.system.module.entity.SysUserEntity;
|
||||
@@ -31,16 +39,19 @@ import com.starry.admin.modules.system.service.ISysTenantService;
|
||||
import com.starry.admin.modules.system.service.SysUserService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.UserConstants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -66,9 +77,11 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
public static final String DEFAULT_COMMODITY_ID = "svc-basic";
|
||||
public static final String DEFAULT_CLERK_COMMODITY_ID = "clerk-svc-basic";
|
||||
public static final String DEFAULT_CUSTOMER_ID = "customer-apitest";
|
||||
public static final String DEFAULT_CUSTOMER_OPEN_ID = "openid-customer-apitest";
|
||||
public static final String DEFAULT_GIFT_ID = "gift-basic";
|
||||
public static final String DEFAULT_GIFT_NAME = "API测试礼物";
|
||||
public static final BigDecimal DEFAULT_COMMODITY_PRICE = new BigDecimal("120.00");
|
||||
public static final BigDecimal E2E_CUSTOMER_BALANCE = new BigDecimal("1000.00");
|
||||
private static final String GIFT_TYPE_REGULAR = "1";
|
||||
private static final String GIFT_STATE_ACTIVE = "0";
|
||||
private static final BigDecimal DEFAULT_CUSTOMER_BALANCE = new BigDecimal("200.00");
|
||||
@@ -77,6 +90,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
private final ISysTenantPackageService tenantPackageService;
|
||||
private final ISysTenantService tenantService;
|
||||
private final SysUserService sysUserService;
|
||||
private final SysUserMapper sysUserMapper;
|
||||
private final SysMenuMapper sysMenuMapper;
|
||||
private final IPlayPersonnelGroupInfoService personnelGroupInfoService;
|
||||
private final IPlayClerkLevelInfoService clerkLevelInfoService;
|
||||
private final IPlayClerkUserInfoService clerkUserInfoService;
|
||||
@@ -84,6 +99,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
private final IPlayCommodityAndLevelInfoService commodityAndLevelInfoService;
|
||||
private final IPlayGiftInfoService giftInfoService;
|
||||
private final IPlayClerkCommodityService clerkCommodityService;
|
||||
private final PlayClerkUserInfoMapper clerkUserInfoMapper;
|
||||
private final PlayCommodityInfoMapper commodityInfoMapper;
|
||||
private final IPlayClerkGiftInfoService playClerkGiftInfoService;
|
||||
private final IPlayCustomUserInfoService customUserInfoService;
|
||||
private final IPlayCustomGiftInfoService playCustomGiftInfoService;
|
||||
@@ -96,6 +113,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
ISysTenantPackageService tenantPackageService,
|
||||
ISysTenantService tenantService,
|
||||
SysUserService sysUserService,
|
||||
SysUserMapper sysUserMapper,
|
||||
SysMenuMapper sysMenuMapper,
|
||||
IPlayPersonnelGroupInfoService personnelGroupInfoService,
|
||||
IPlayClerkLevelInfoService clerkLevelInfoService,
|
||||
IPlayClerkUserInfoService clerkUserInfoService,
|
||||
@@ -103,6 +122,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
IPlayCommodityAndLevelInfoService commodityAndLevelInfoService,
|
||||
IPlayGiftInfoService giftInfoService,
|
||||
IPlayClerkCommodityService clerkCommodityService,
|
||||
PlayClerkUserInfoMapper clerkUserInfoMapper,
|
||||
PlayCommodityInfoMapper commodityInfoMapper,
|
||||
IPlayClerkGiftInfoService playClerkGiftInfoService,
|
||||
IPlayCustomUserInfoService customUserInfoService,
|
||||
IPlayCustomGiftInfoService playCustomGiftInfoService,
|
||||
@@ -113,6 +134,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
this.tenantPackageService = tenantPackageService;
|
||||
this.tenantService = tenantService;
|
||||
this.sysUserService = sysUserService;
|
||||
this.sysUserMapper = sysUserMapper;
|
||||
this.sysMenuMapper = sysMenuMapper;
|
||||
this.personnelGroupInfoService = personnelGroupInfoService;
|
||||
this.clerkLevelInfoService = clerkLevelInfoService;
|
||||
this.clerkUserInfoService = clerkUserInfoService;
|
||||
@@ -120,6 +143,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
this.commodityAndLevelInfoService = commodityAndLevelInfoService;
|
||||
this.giftInfoService = giftInfoService;
|
||||
this.clerkCommodityService = clerkCommodityService;
|
||||
this.clerkUserInfoMapper = clerkUserInfoMapper;
|
||||
this.commodityInfoMapper = commodityInfoMapper;
|
||||
this.playClerkGiftInfoService = playClerkGiftInfoService;
|
||||
this.customUserInfoService = customUserInfoService;
|
||||
this.playCustomGiftInfoService = playCustomGiftInfoService;
|
||||
@@ -132,6 +157,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(String... args) {
|
||||
seedPcTenantWagesMenu();
|
||||
seedTenantPackage();
|
||||
seedTenant();
|
||||
|
||||
@@ -156,6 +182,98 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private void seedPcTenantWagesMenu() {
|
||||
// Minimal menu tree for pc-tenant E2E: /play/clerk/wages -> play/clerk/wages/index.vue
|
||||
// This is apitest-only; prod/dev menus are managed by ops/admin tooling.
|
||||
SysMenuEntity playRoot = ensureMenu(
|
||||
"陪聊管理",
|
||||
"PlayManage",
|
||||
0L,
|
||||
UserConstants.TYPE_DIR,
|
||||
"/play",
|
||||
UserConstants.LAYOUT,
|
||||
50);
|
||||
|
||||
SysMenuEntity clerkDir = ensureMenu(
|
||||
"店员管理",
|
||||
"ClerkManage",
|
||||
playRoot.getMenuId(),
|
||||
UserConstants.TYPE_DIR,
|
||||
"clerk",
|
||||
"",
|
||||
1);
|
||||
|
||||
ensureMenu(
|
||||
"收益管理",
|
||||
"ClerkWages",
|
||||
clerkDir.getMenuId(),
|
||||
UserConstants.TYPE_MENU,
|
||||
"wages",
|
||||
"play/clerk/wages/index",
|
||||
1);
|
||||
}
|
||||
|
||||
private SysMenuEntity ensureMenu(
|
||||
String menuName,
|
||||
String menuCode,
|
||||
Long parentId,
|
||||
String menuType,
|
||||
String path,
|
||||
String component,
|
||||
Integer sort) {
|
||||
Optional<SysMenuEntity> existing = sysMenuMapper.selectList(Wrappers.<SysMenuEntity>lambdaQuery()
|
||||
.eq(SysMenuEntity::getDeleted, false)
|
||||
.eq(SysMenuEntity::getParentId, parentId)
|
||||
.eq(SysMenuEntity::getMenuCode, menuCode)
|
||||
.last("limit 1"))
|
||||
.stream()
|
||||
.findFirst();
|
||||
if (existing.isPresent()) {
|
||||
SysMenuEntity current = existing.get();
|
||||
boolean changed = false;
|
||||
if (!Objects.equals(current.getPath(), path)) {
|
||||
current.setPath(path);
|
||||
changed = true;
|
||||
}
|
||||
if (!Objects.equals(current.getComponent(), component)) {
|
||||
current.setComponent(component);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
current.setUpdatedBy("apitest-seed");
|
||||
current.setUpdatedTime(new Date());
|
||||
sysMenuMapper.updateById(current);
|
||||
log.info("Updated apitest sys_menu '{}' path='{}' component='{}'", menuName, path, component);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
SysMenuEntity entity = new SysMenuEntity();
|
||||
entity.setMenuName(menuName);
|
||||
entity.setMenuCode(menuCode);
|
||||
entity.setIcon("el-icon-menu");
|
||||
entity.setPermission("");
|
||||
entity.setMenuLevel(parentId == 0 ? 1L : 2L);
|
||||
entity.setSort(sort);
|
||||
entity.setParentId(parentId);
|
||||
entity.setMenuType(menuType);
|
||||
entity.setStatus(0);
|
||||
entity.setRemark(menuName);
|
||||
entity.setPath(path);
|
||||
entity.setComponent(component);
|
||||
entity.setRouterQuery("");
|
||||
entity.setIsFrame(0);
|
||||
entity.setVisible(1);
|
||||
entity.setDeleted(Boolean.FALSE);
|
||||
entity.setCreatedBy("apitest-seed");
|
||||
entity.setCreatedTime(new Date());
|
||||
entity.setUpdatedBy("apitest-seed");
|
||||
entity.setUpdatedTime(new Date());
|
||||
sysMenuMapper.insert(entity);
|
||||
log.info("Inserted apitest sys_menu '{}' path='{}' parentId={}", menuName, path, parentId);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private void seedTenantPackage() {
|
||||
long existing = tenantPackageService.count(Wrappers.<SysTenantPackageEntity>lambdaQuery()
|
||||
.eq(SysTenantPackageEntity::getPackageId, DEFAULT_PACKAGE_ID));
|
||||
@@ -177,6 +295,28 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
private void seedTenant() {
|
||||
SysTenantEntity tenant = tenantService.getById(DEFAULT_TENANT_ID);
|
||||
if (tenant != null) {
|
||||
boolean changed = false;
|
||||
if (tenant.getAppId() == null || tenant.getAppId().isEmpty()) {
|
||||
tenant.setAppId("wx-apitest-appid");
|
||||
changed = true;
|
||||
}
|
||||
if (tenant.getSecret() == null || tenant.getSecret().isEmpty()) {
|
||||
tenant.setSecret("wx-apitest-secret");
|
||||
changed = true;
|
||||
}
|
||||
if (tenant.getMchId() == null || tenant.getMchId().isEmpty()) {
|
||||
tenant.setMchId("wx-apitest-mchid");
|
||||
changed = true;
|
||||
}
|
||||
if (tenant.getMchKey() == null || tenant.getMchKey().isEmpty()) {
|
||||
tenant.setMchKey("wx-apitest-mchkey");
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
tenantService.updateById(tenant);
|
||||
log.info("API test tenant {} already exists, wechat config refreshed", DEFAULT_TENANT_ID);
|
||||
return;
|
||||
}
|
||||
log.info("API test tenant {} already exists", DEFAULT_TENANT_ID);
|
||||
return;
|
||||
}
|
||||
@@ -188,6 +328,10 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
entity.setTenantStatus("0");
|
||||
entity.setTenantCode("apitest");
|
||||
entity.setTenantKey(DEFAULT_TENANT_KEY);
|
||||
entity.setAppId("wx-apitest-appid");
|
||||
entity.setSecret("wx-apitest-secret");
|
||||
entity.setMchId("wx-apitest-mchid");
|
||||
entity.setMchKey("wx-apitest-mchkey");
|
||||
entity.setPackageId(DEFAULT_PACKAGE_ID);
|
||||
entity.setTenantTime(new Date(System.currentTimeMillis() + 365L * 24 * 3600 * 1000));
|
||||
entity.setUserName(DEFAULT_ADMIN_USERNAME);
|
||||
@@ -200,7 +344,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
}
|
||||
|
||||
private void seedAdminUser() {
|
||||
SysUserEntity existing = sysUserService.getById(DEFAULT_ADMIN_USER_ID);
|
||||
SysUserEntity existing = sysUserMapper.selectUserByUserNameAndTenantId(
|
||||
DEFAULT_ADMIN_USERNAME, DEFAULT_TENANT_ID);
|
||||
if (existing != null) {
|
||||
log.info("API test admin user {} already exists", DEFAULT_ADMIN_USER_ID);
|
||||
return;
|
||||
@@ -237,8 +382,12 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
entity.setGroupName("测试小组");
|
||||
entity.setLeaderName("API Admin");
|
||||
entity.setAddTime(LocalDateTime.now());
|
||||
personnelGroupInfoService.save(entity);
|
||||
log.info("Inserted API test personnel group {}", DEFAULT_GROUP_ID);
|
||||
try {
|
||||
personnelGroupInfoService.save(entity);
|
||||
log.info("Inserted API test personnel group {}", DEFAULT_GROUP_ID);
|
||||
} catch (DuplicateKeyException duplicateKeyException) {
|
||||
log.info("API test personnel group {} already inserted by another test context", DEFAULT_GROUP_ID);
|
||||
}
|
||||
}
|
||||
|
||||
private void seedClerkLevel() {
|
||||
@@ -260,12 +409,27 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
entity.setFirstRewardRatio(40);
|
||||
entity.setNotFirstRewardRatio(35);
|
||||
entity.setOrderNumber(1L);
|
||||
clerkLevelInfoService.save(entity);
|
||||
log.info("Inserted API test clerk level {}", DEFAULT_CLERK_LEVEL_ID);
|
||||
try {
|
||||
clerkLevelInfoService.save(entity);
|
||||
log.info("Inserted API test clerk level {}", DEFAULT_CLERK_LEVEL_ID);
|
||||
} catch (DuplicateKeyException duplicateKeyException) {
|
||||
log.info("API test clerk level {} already inserted by another test context", DEFAULT_CLERK_LEVEL_ID);
|
||||
}
|
||||
}
|
||||
|
||||
private PlayCommodityInfoEntity seedCommodityHierarchy() {
|
||||
PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
|
||||
if (parent == null) {
|
||||
PlayCommodityInfoEntity existingParent = commodityInfoMapper
|
||||
.selectByIdIncludingDeleted(DEFAULT_COMMODITY_PARENT_ID);
|
||||
if (existingParent != null) {
|
||||
commodityInfoMapper.restoreCommodity(DEFAULT_COMMODITY_PARENT_ID, DEFAULT_TENANT_ID);
|
||||
parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
|
||||
if (parent == null) {
|
||||
parent = existingParent;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parent == null) {
|
||||
parent = new PlayCommodityInfoEntity();
|
||||
parent.setId(DEFAULT_COMMODITY_PARENT_ID);
|
||||
@@ -302,6 +466,17 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
}
|
||||
|
||||
PlayCommodityInfoEntity child = commodityInfoService.getById(DEFAULT_COMMODITY_ID);
|
||||
if (child == null) {
|
||||
PlayCommodityInfoEntity existingChild = commodityInfoMapper
|
||||
.selectByIdIncludingDeleted(DEFAULT_COMMODITY_ID);
|
||||
if (existingChild != null) {
|
||||
commodityInfoMapper.restoreCommodity(DEFAULT_COMMODITY_ID, DEFAULT_TENANT_ID);
|
||||
child = commodityInfoService.getById(DEFAULT_COMMODITY_ID);
|
||||
if (child == null) {
|
||||
child = existingChild;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (child != null) {
|
||||
boolean childNeedsUpdate = false;
|
||||
if (!DEFAULT_COMMODITY_PARENT_ID.equals(child.getPId())) {
|
||||
@@ -370,8 +545,27 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(DEFAULT_CLERK_ID);
|
||||
String clerkToken = wxTokenService.createWxUserToken(DEFAULT_CLERK_ID);
|
||||
if (clerk != null) {
|
||||
clerkUserInfoService.updateTokenById(DEFAULT_CLERK_ID, clerkToken);
|
||||
log.info("API test clerk {} already exists", DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.update(Wrappers.<PlayClerkUserInfoEntity>lambdaUpdate()
|
||||
.eq(PlayClerkUserInfoEntity::getId, DEFAULT_CLERK_ID)
|
||||
.set(PlayClerkUserInfoEntity::getDeleted, Boolean.FALSE)
|
||||
.set(PlayClerkUserInfoEntity::getOnboardingState, OnboardingStatus.ACTIVE.getCode())
|
||||
.set(PlayClerkUserInfoEntity::getListingState, ListingStatus.LISTED.getCode())
|
||||
.set(PlayClerkUserInfoEntity::getDisplayState, "1")
|
||||
.set(PlayClerkUserInfoEntity::getRandomOrderState, "1")
|
||||
.set(PlayClerkUserInfoEntity::getClerkState, ClerkRoleStatus.CLERK.getCode())
|
||||
.set(PlayClerkUserInfoEntity::getOnlineState, "1")
|
||||
.set(PlayClerkUserInfoEntity::getAvatar, "https://example.com/avatar.png")
|
||||
.set(PlayClerkUserInfoEntity::getToken, clerkToken));
|
||||
log.info("API test clerk {} already exists, state refreshed", DEFAULT_CLERK_ID);
|
||||
return;
|
||||
}
|
||||
PlayClerkUserInfoEntity existing = clerkUserInfoMapper.selectByIdIncludingDeleted(DEFAULT_CLERK_ID);
|
||||
if (existing != null) {
|
||||
clerkUserInfoService.update(Wrappers.<PlayClerkUserInfoEntity>lambdaUpdate()
|
||||
.eq(PlayClerkUserInfoEntity::getId, DEFAULT_CLERK_ID)
|
||||
.set(PlayClerkUserInfoEntity::getDeleted, Boolean.FALSE)
|
||||
.set(PlayClerkUserInfoEntity::getToken, clerkToken));
|
||||
log.info("API test clerk {} restored from deleted state", DEFAULT_CLERK_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -403,26 +597,38 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
|
||||
private void seedClerkCommodity() {
|
||||
PlayClerkCommodityEntity mapping = clerkCommodityService.getById(DEFAULT_CLERK_COMMODITY_ID);
|
||||
if (mapping != null) {
|
||||
log.info("API test clerk commodity {} already exists", DEFAULT_CLERK_COMMODITY_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
String commodityName = DEFAULT_COMMODITY_PARENT_NAME;
|
||||
PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
|
||||
if (parent != null && parent.getItemName() != null) {
|
||||
commodityName = parent.getItemName();
|
||||
}
|
||||
|
||||
if (mapping != null) {
|
||||
clerkCommodityService.update(Wrappers.<PlayClerkCommodityEntity>lambdaUpdate()
|
||||
.eq(PlayClerkCommodityEntity::getId, DEFAULT_CLERK_COMMODITY_ID)
|
||||
.set(PlayClerkCommodityEntity::getCommodityId, DEFAULT_COMMODITY_PARENT_ID)
|
||||
.set(PlayClerkCommodityEntity::getCommodityName, commodityName)
|
||||
.set(PlayClerkCommodityEntity::getEnablingState, "1"));
|
||||
log.info("API test clerk commodity {} already exists, state refreshed", DEFAULT_CLERK_COMMODITY_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
PlayClerkCommodityEntity entity = new PlayClerkCommodityEntity();
|
||||
entity.setId(DEFAULT_CLERK_COMMODITY_ID);
|
||||
entity.setTenantId(DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(DEFAULT_CLERK_ID);
|
||||
entity.setCommodityId(DEFAULT_COMMODITY_ID);
|
||||
entity.setCommodityId(DEFAULT_COMMODITY_PARENT_ID);
|
||||
entity.setCommodityName(commodityName);
|
||||
entity.setEnablingState("1");
|
||||
entity.setSort(1);
|
||||
clerkCommodityService.save(entity);
|
||||
try {
|
||||
clerkCommodityService.save(entity);
|
||||
} catch (DuplicateKeyException duplicateKeyException) {
|
||||
log.info(
|
||||
"API test clerk commodity {} already inserted by another test context",
|
||||
DEFAULT_CLERK_COMMODITY_ID);
|
||||
return;
|
||||
}
|
||||
log.info("Inserted API test clerk commodity link {}", DEFAULT_CLERK_COMMODITY_ID);
|
||||
}
|
||||
|
||||
@@ -445,7 +651,12 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
entity.setState(GIFT_STATE_ACTIVE);
|
||||
entity.setListingTime(LocalDateTime.now());
|
||||
entity.setRemark("Seeded gift for API tests");
|
||||
giftInfoService.save(entity);
|
||||
try {
|
||||
giftInfoService.save(entity);
|
||||
} catch (DuplicateKeyException duplicateKeyException) {
|
||||
log.info("API test gift {} already inserted by another test context", DEFAULT_GIFT_ID);
|
||||
return;
|
||||
}
|
||||
log.info("Inserted API test gift {}", DEFAULT_GIFT_ID);
|
||||
}
|
||||
|
||||
@@ -504,7 +715,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
|
||||
entity.setId(DEFAULT_CUSTOMER_ID);
|
||||
entity.setTenantId(DEFAULT_TENANT_ID);
|
||||
entity.setOpenid("openid-customer-apitest");
|
||||
entity.setOpenid(DEFAULT_CUSTOMER_OPEN_ID);
|
||||
entity.setUnionid("unionid-customer-apitest");
|
||||
entity.setNickname("测试顾客");
|
||||
entity.setSex(1);
|
||||
@@ -520,7 +731,24 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
entity.setRegistrationTime(new Date());
|
||||
entity.setLastLoginTime(new Date());
|
||||
entity.setToken(token);
|
||||
customUserInfoService.save(entity);
|
||||
try {
|
||||
customUserInfoService.save(entity);
|
||||
} catch (DuplicateKeyException duplicateKeyException) {
|
||||
customUserInfoService.updateTokenById(DEFAULT_CUSTOMER_ID, token);
|
||||
customUserInfoService.lambdaUpdate()
|
||||
.set(PlayCustomUserInfoEntity::getAccountBalance, DEFAULT_CUSTOMER_BALANCE)
|
||||
.set(PlayCustomUserInfoEntity::getAccumulatedRechargeAmount, DEFAULT_CUSTOMER_RECHARGE)
|
||||
.set(PlayCustomUserInfoEntity::getAccumulatedConsumptionAmount, BigDecimal.ZERO)
|
||||
.set(PlayCustomUserInfoEntity::getAccountState, "1")
|
||||
.set(PlayCustomUserInfoEntity::getSubscribeState, "1")
|
||||
.set(PlayCustomUserInfoEntity::getPurchaseState, "1")
|
||||
.set(PlayCustomUserInfoEntity::getMobilePhoneState, "1")
|
||||
.set(PlayCustomUserInfoEntity::getLastLoginTime, new Date())
|
||||
.eq(PlayCustomUserInfoEntity::getId, DEFAULT_CUSTOMER_ID)
|
||||
.update();
|
||||
log.info("API test customer {} already inserted by another test context", DEFAULT_CUSTOMER_ID);
|
||||
return;
|
||||
}
|
||||
log.info("Inserted API test customer {}", DEFAULT_CUSTOMER_ID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ public class PermissionService {
|
||||
}
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions())) {
|
||||
return false;
|
||||
return loginUser != null && loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser());
|
||||
}
|
||||
if (loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser())) {
|
||||
return true;
|
||||
}
|
||||
return hasPermissions(loginUser.getPermissions(), permission);
|
||||
}
|
||||
@@ -70,7 +73,13 @@ public class PermissionService {
|
||||
return false;
|
||||
}
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions())) {
|
||||
if (loginUser == null) {
|
||||
return false;
|
||||
}
|
||||
if (loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser())) {
|
||||
return true;
|
||||
}
|
||||
if (CollectionUtils.isEmpty(loginUser.getPermissions())) {
|
||||
return false;
|
||||
}
|
||||
Set<String> authorities = loginUser.getPermissions();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.starry.admin.common.config;
|
||||
|
||||
import com.starry.admin.modules.weichat.constant.WebSocketConstant;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
/**
|
||||
* WebSocket 配置,基于 STOMP 的简单消息代理。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
private static final String APPLICATION_DESTINATION_PREFIX = "/app";
|
||||
private static final String TOPIC_DESTINATION_PREFIX = "/topic";
|
||||
private static final String PK_ENDPOINT = "/ws/pk";
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry registry) {
|
||||
registry.enableSimpleBroker(TOPIC_DESTINATION_PREFIX);
|
||||
registry.setApplicationDestinationPrefixes(APPLICATION_DESTINATION_PREFIX);
|
||||
registry.setUserDestinationPrefix(WebSocketConstant.USER_DESTINATION_PREFIX);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint(PK_ENDPOINT).setAllowedOriginPatterns("*");
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,11 @@ import com.starry.common.utils.StringUtils;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
@@ -24,6 +28,17 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
public static final String PARAMETER_FORMAT_ERROR = "请求参数格式异常";
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<R> handleAccessDenied(AccessDeniedException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(R.error(403, e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
public ResponseEntity<R> handleAuthentication(AuthenticationException e) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(R.error(401, e.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务异常
|
||||
@@ -87,20 +102,20 @@ public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MismatchedInputException.class)
|
||||
public R mismatchedInputException(MismatchedInputException e) {
|
||||
log.error("请求参数格式异常", e);
|
||||
return R.error("请求参数格式异常");
|
||||
log.error(PARAMETER_FORMAT_ERROR, e);
|
||||
return R.error(PARAMETER_FORMAT_ERROR);
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public R httpMessageNotReadableException(HttpMessageNotReadableException e) {
|
||||
log.error("请求参数格式异常", e);
|
||||
return R.error("请求参数格式异常");
|
||||
log.error(PARAMETER_FORMAT_ERROR, e);
|
||||
return R.error(PARAMETER_FORMAT_ERROR);
|
||||
}
|
||||
|
||||
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||
public R missingServletRequestParameterException(MissingServletRequestParameterException e) {
|
||||
log.error("请求参数格式异常", e);
|
||||
return R.error("请求参数格式异常");
|
||||
log.error(PARAMETER_FORMAT_ERROR, e);
|
||||
return R.error(PARAMETER_FORMAT_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,10 +6,14 @@ import com.starry.admin.modules.system.module.entity.SysUserEntity;
|
||||
import com.starry.common.constant.SecurityConstants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
@@ -24,6 +28,8 @@ import org.springframework.web.filter.OncePerRequestFilter;
|
||||
public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final ApiTestSecurityProperties properties;
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin";
|
||||
|
||||
public ApiTestAuthenticationFilter(ApiTestSecurityProperties properties) {
|
||||
this.properties = properties;
|
||||
@@ -32,6 +38,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
Map<String, Object> originalContext = new ConcurrentHashMap<>(CustomSecurityContextHolder.getLocalMap());
|
||||
String requestedUser = request.getHeader(properties.getUserHeader());
|
||||
String requestedTenant = request.getHeader(properties.getTenantHeader());
|
||||
|
||||
@@ -48,6 +55,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
try {
|
||||
LoginUser loginUser = buildLoginUser(userId, tenantId);
|
||||
applyOverridesFromHeaders(request, loginUser);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null,
|
||||
Collections.emptyList());
|
||||
@@ -61,7 +69,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
CustomSecurityContextHolder.remove();
|
||||
CustomSecurityContextHolder.setLocalMap(originalContext);
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
@@ -93,4 +101,27 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
return loginUser;
|
||||
}
|
||||
|
||||
private void applyOverridesFromHeaders(HttpServletRequest request, LoginUser loginUser) {
|
||||
if (loginUser == null || loginUser.getUser() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String superAdmin = request.getHeader(SUPER_ADMIN_HEADER);
|
||||
if (StringUtils.hasText(superAdmin)) {
|
||||
loginUser.getUser().setSuperAdmin(Boolean.parseBoolean(superAdmin));
|
||||
}
|
||||
|
||||
String permissionsHeader = request.getHeader(PERMISSIONS_HEADER);
|
||||
if (!StringUtils.hasText(permissionsHeader)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> perms = Arrays.stream(permissionsHeader.split(","))
|
||||
.map(String::trim)
|
||||
.filter(StringUtils::hasText)
|
||||
.collect(Collectors.toSet());
|
||||
loginUser.setPermissions(perms);
|
||||
CustomSecurityContextHolder.setPermission(String.join(",", perms));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ package com.starry.admin.modules.clerk.controller;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
|
||||
import com.starry.admin.modules.pk.dto.PkScoreBoardDto;
|
||||
import com.starry.admin.modules.pk.dto.PlayClerkPkForceStartRequest;
|
||||
import com.starry.admin.modules.pk.service.ClerkPkLifecycleService;
|
||||
import com.starry.admin.modules.pk.service.IPkScoreboardService;
|
||||
import com.starry.common.annotation.Log;
|
||||
import com.starry.common.enums.BusinessType;
|
||||
import com.starry.common.result.R;
|
||||
@@ -13,7 +17,13 @@ import io.swagger.annotations.ApiParam;
|
||||
import io.swagger.annotations.ApiResponse;
|
||||
import io.swagger.annotations.ApiResponses;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 店员pkController
|
||||
@@ -28,6 +38,12 @@ public class PlayClerkPkController {
|
||||
@Resource
|
||||
private IPlayClerkPkService playClerkPkService;
|
||||
|
||||
@Resource
|
||||
private IPkScoreboardService pkScoreboardService;
|
||||
|
||||
@Resource
|
||||
private ClerkPkLifecycleService clerkPkLifecycleService;
|
||||
|
||||
/**
|
||||
* 查询店员pk列表
|
||||
*/
|
||||
@@ -51,6 +67,52 @@ public class PlayClerkPkController {
|
||||
return R.ok(playClerkPkService.selectPlayClerkPkById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取店员PK实时比分
|
||||
*/
|
||||
@ApiOperation(value = "获取PK实时比分", notes = "根据ID获取店员PK当前比分")
|
||||
@ApiImplicitParam(name = "id", value = "PK记录ID", required = true, paramType = "path", dataType = "String", example = "1")
|
||||
@ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = PkScoreBoardDto.class)})
|
||||
@GetMapping(value = "/{id}/scoreboard")
|
||||
public R getScoreboard(@PathVariable("id") String id) {
|
||||
PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(id);
|
||||
return R.ok(scoreboard);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动开始PK
|
||||
*/
|
||||
@ApiOperation(value = "开始PK", notes = "将指定PK从待开始状态切换为进行中")
|
||||
@ApiImplicitParam(name = "id", value = "PK记录ID", required = true, paramType = "path", dataType = "String", example = "1")
|
||||
@PostMapping(value = "/{id}/start")
|
||||
public R startPk(@PathVariable("id") String id) {
|
||||
clerkPkLifecycleService.startPk(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动结束并结算PK
|
||||
*/
|
||||
@ApiOperation(value = "结束PK并结算", notes = "将指定PK标记为已完成,并写入最终比分和胜者信息")
|
||||
@ApiImplicitParam(name = "id", value = "PK记录ID", required = true, paramType = "path", dataType = "String", example = "1")
|
||||
@PostMapping(value = "/{id}/finish")
|
||||
public R finishPk(@PathVariable("id") String id) {
|
||||
clerkPkLifecycleService.finishPk(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制开始PK(无需排期,便于人工触发)
|
||||
*/
|
||||
@ApiOperation(value = "强制开始PK", notes = "人工触发PK开始并直接进入进行中状态")
|
||||
@ApiResponses({@ApiResponse(code = 200, message = "操作成功")})
|
||||
@Log(title = "店员pk", businessType = BusinessType.INSERT)
|
||||
@PostMapping(value = "/force-start")
|
||||
public R forceStart(@ApiParam(value = "强制开始请求", required = true)
|
||||
@RequestBody PlayClerkPkForceStartRequest request) {
|
||||
return R.ok(clerkPkLifecycleService.forceStart(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增店员pk
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.starry.admin.modules.clerk.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/**
|
||||
* 店员pkMapper接口
|
||||
@@ -11,4 +16,47 @@ import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
*/
|
||||
public interface PlayClerkPkMapper extends BaseMapper<PlayClerkPkEntity> {
|
||||
|
||||
@InterceptorIgnore(tenantLine = "1")
|
||||
@Select("SELECT * FROM play_clerk_pk "
|
||||
+ "WHERE status = #{status} "
|
||||
+ "AND pk_begin_time >= #{beginTime} "
|
||||
+ "AND pk_begin_time <= #{endTime}")
|
||||
List<PlayClerkPkEntity> selectUpcomingByStatus(
|
||||
@Param("status") String status,
|
||||
@Param("beginTime") Date beginTime,
|
||||
@Param("endTime") Date endTime);
|
||||
|
||||
@Select("<script>"
|
||||
+ "SELECT * FROM play_clerk_pk "
|
||||
+ "WHERE tenant_id = #{tenantId} "
|
||||
+ " AND status = #{status} "
|
||||
+ " AND ("
|
||||
+ " (clerk_a = #{clerkAId} AND clerk_b = #{clerkBId}) "
|
||||
+ " OR (clerk_a = #{clerkBId} AND clerk_b = #{clerkAId})"
|
||||
+ " ) "
|
||||
+ "ORDER BY pk_begin_time DESC "
|
||||
+ "LIMIT #{limit}"
|
||||
+ "</script>")
|
||||
List<PlayClerkPkEntity> selectRecentFinishedBetweenClerks(
|
||||
@Param("tenantId") String tenantId,
|
||||
@Param("clerkAId") String clerkAId,
|
||||
@Param("clerkBId") String clerkBId,
|
||||
@Param("status") String status,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@Select("<script>"
|
||||
+ "SELECT * FROM play_clerk_pk "
|
||||
+ "WHERE tenant_id = #{tenantId} "
|
||||
+ " AND status = #{status} "
|
||||
+ " AND pk_begin_time >= #{beginTime} "
|
||||
+ " AND (clerk_a = #{clerkId} OR clerk_b = #{clerkId}) "
|
||||
+ "ORDER BY pk_begin_time ASC "
|
||||
+ "LIMIT #{limit}"
|
||||
+ "</script>")
|
||||
List<PlayClerkPkEntity> selectUpcomingForClerk(
|
||||
@Param("tenantId") String tenantId,
|
||||
@Param("clerkId") String clerkId,
|
||||
@Param("status") String status,
|
||||
@Param("beginTime") Date beginTime,
|
||||
@Param("limit") int limit);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||
import com.github.yulichang.base.MPJBaseMapper;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/**
|
||||
@@ -17,4 +18,8 @@ public interface PlayClerkUserInfoMapper extends MPJBaseMapper<PlayClerkUserInfo
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("SELECT id, tenant_id, album FROM play_clerk_user_info WHERE deleted = 0 AND album IS NOT NULL")
|
||||
List<PlayClerkUserInfoEntity> selectAllWithAlbumIgnoringTenant();
|
||||
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("SELECT id, tenant_id, deleted FROM play_clerk_user_info WHERE id = #{id} LIMIT 1")
|
||||
PlayClerkUserInfoEntity selectByIdIncludingDeleted(@Param("id") String id);
|
||||
}
|
||||
|
||||
@@ -87,4 +87,39 @@ public class PlayClerkPkEntity extends BaseEntity<PlayClerkPkEntity> {
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 排期设置ID
|
||||
*/
|
||||
private String settingId;
|
||||
|
||||
/**
|
||||
* 店员A得分
|
||||
*/
|
||||
private java.math.BigDecimal clerkAScore;
|
||||
|
||||
/**
|
||||
* 店员B得分
|
||||
*/
|
||||
private java.math.BigDecimal clerkBScore;
|
||||
|
||||
/**
|
||||
* 店员A订单数
|
||||
*/
|
||||
private Integer clerkAOrderCount;
|
||||
|
||||
/**
|
||||
* 店员B订单数
|
||||
*/
|
||||
private Integer clerkBOrderCount;
|
||||
|
||||
/**
|
||||
* 获胜店员ID
|
||||
*/
|
||||
private String winnerClerkId;
|
||||
|
||||
/**
|
||||
* 是否已结算(1:是;0:否)
|
||||
*/
|
||||
private Integer settled;
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ package com.starry.admin.modules.clerk.service;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 店员pkService接口
|
||||
@@ -64,4 +68,15 @@ public interface IPlayClerkPkService extends IService<PlayClerkPkEntity> {
|
||||
* @return 结果
|
||||
*/
|
||||
int deletePlayClerkPkById(String id);
|
||||
|
||||
/**
|
||||
* 查询某个店员在指定时间是否存在进行中的 PK。
|
||||
*
|
||||
* @param clerkId 店员ID
|
||||
* @param occurredAt 发生时间
|
||||
* @return 存在则返回 PK 记录,否则返回空
|
||||
*/
|
||||
Optional<PlayClerkPkEntity> findActivePkForClerk(String clerkId, LocalDateTime occurredAt);
|
||||
|
||||
List<PlayClerkPkEntity> selectUpcomingForClerk(String tenantId, String clerkId, Date beginTime, int limit);
|
||||
}
|
||||
|
||||
@@ -7,16 +7,25 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.starry.admin.common.PageBuilder;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.clerk.mapper.PlayClerkPkMapper;
|
||||
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.pk.enums.PkLifecycleErrorCode;
|
||||
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
@@ -33,6 +42,8 @@ public class PlayClerkPkServiceImpl extends ServiceImpl<PlayClerkPkMapper, PlayC
|
||||
private PlayClerkPkMapper playClerkPkMapper;
|
||||
@Resource
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
@Resource
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
/**
|
||||
* 查询店员pk
|
||||
@@ -55,8 +66,15 @@ public class PlayClerkPkServiceImpl extends ServiceImpl<PlayClerkPkMapper, PlayC
|
||||
*/
|
||||
@Override
|
||||
public IPage<PlayClerkPkEntity> selectPlayClerkPkByPage(PlayClerkPkEntity playClerkPk) {
|
||||
Page<PlayClerkPkEntity> page = new Page<>(1, 10);
|
||||
return this.baseMapper.selectPage(page, new LambdaQueryWrapper<>());
|
||||
Page<PlayClerkPkEntity> page = PageBuilder.build();
|
||||
LambdaQueryWrapper<PlayClerkPkEntity> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(StrUtil.isNotBlank(playClerkPk.getStatus()), PlayClerkPkEntity::getStatus, playClerkPk.getStatus());
|
||||
wrapper.eq(StrUtil.isNotBlank(playClerkPk.getClerkA()), PlayClerkPkEntity::getClerkA, playClerkPk.getClerkA());
|
||||
wrapper.eq(StrUtil.isNotBlank(playClerkPk.getClerkB()), PlayClerkPkEntity::getClerkB, playClerkPk.getClerkB());
|
||||
wrapper.eq(StrUtil.isNotBlank(playClerkPk.getSettingId()), PlayClerkPkEntity::getSettingId,
|
||||
playClerkPk.getSettingId());
|
||||
wrapper.orderByDesc(PlayClerkPkEntity::getPkBeginTime);
|
||||
return this.baseMapper.selectPage(page, wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,7 +116,11 @@ public class PlayClerkPkServiceImpl extends ServiceImpl<PlayClerkPkMapper, PlayC
|
||||
}
|
||||
|
||||
playClerkPk.setStatus(ClerkPkEnum.TO_BE_STARTED.name());
|
||||
return save(playClerkPk);
|
||||
boolean saved = save(playClerkPk);
|
||||
if (saved) {
|
||||
scheduleStart(playClerkPk);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,4 +158,49 @@ public class PlayClerkPkServiceImpl extends ServiceImpl<PlayClerkPkMapper, PlayC
|
||||
public int deletePlayClerkPkById(String id) {
|
||||
return playClerkPkMapper.deleteById(id);
|
||||
}
|
||||
|
||||
private void scheduleStart(PlayClerkPkEntity pk) {
|
||||
if (pk == null || pk.getPkBeginTime() == null || pk.getId() == null) {
|
||||
throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
if (StrUtil.isBlank(pk.getTenantId())) {
|
||||
throw new CustomException(PkLifecycleErrorCode.TENANT_MISSING.getMessage());
|
||||
}
|
||||
String scheduleKey = PkRedisKeyConstants.startScheduleKey(pk.getTenantId());
|
||||
long startEpochSeconds = pk.getPkBeginTime().toInstant().getEpochSecond();
|
||||
stringRedisTemplate.opsForZSet().add(scheduleKey, pk.getId(), startEpochSeconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PlayClerkPkEntity> findActivePkForClerk(String clerkId, LocalDateTime occurredAt) {
|
||||
if (StrUtil.isBlank(clerkId) || occurredAt == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Date eventTime =
|
||||
Date.from(occurredAt.atZone(ZoneId.systemDefault()).toInstant());
|
||||
LambdaQueryWrapper<PlayClerkPkEntity> wrapper = Wrappers.lambdaQuery(PlayClerkPkEntity.class)
|
||||
.in(PlayClerkPkEntity::getStatus,
|
||||
Arrays.asList(ClerkPkEnum.TO_BE_STARTED.name(), ClerkPkEnum.IN_PROGRESS.name()))
|
||||
.and(w -> w.eq(PlayClerkPkEntity::getClerkA, clerkId)
|
||||
.or()
|
||||
.eq(PlayClerkPkEntity::getClerkB, clerkId))
|
||||
.le(PlayClerkPkEntity::getPkBeginTime, eventTime)
|
||||
.ge(PlayClerkPkEntity::getPkEndTime, eventTime);
|
||||
PlayClerkPkEntity entity = this.getOne(wrapper, false);
|
||||
return Optional.ofNullable(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PlayClerkPkEntity> selectUpcomingForClerk(String tenantId, String clerkId, Date beginTime,
|
||||
int limit) {
|
||||
if (StrUtil.isBlank(tenantId) || StrUtil.isBlank(clerkId) || beginTime == null || limit <= 0) {
|
||||
throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
return playClerkPkMapper.selectUpcomingForClerk(
|
||||
tenantId,
|
||||
clerkId,
|
||||
ClerkPkEnum.TO_BE_STARTED.name(),
|
||||
beginTime,
|
||||
limit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ package com.starry.admin.modules.order.mapper;
|
||||
|
||||
import com.github.yulichang.base.MPJBaseMapper;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.pk.dto.WxPkContributorDto;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/**
|
||||
* 订单Mapper接口
|
||||
@@ -11,4 +17,31 @@ import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
*/
|
||||
public interface PlayOrderInfoMapper extends MPJBaseMapper<PlayOrderInfoEntity> {
|
||||
|
||||
@Select("<script>"
|
||||
+ "SELECT o.purchaser_by AS userId, "
|
||||
+ " u.nickname AS nickname, "
|
||||
+ " COALESCE(SUM(o.final_amount), 0) AS amount "
|
||||
+ "FROM play_order_info o "
|
||||
+ "LEFT JOIN play_custom_user_info u "
|
||||
+ " ON u.id = o.purchaser_by "
|
||||
+ " AND u.tenant_id = o.tenant_id "
|
||||
+ "WHERE o.tenant_id = #{tenantId} "
|
||||
+ " AND o.order_status = #{orderStatus} "
|
||||
+ " AND o.final_amount > #{minAmount} "
|
||||
+ " AND o.accept_by IN (#{clerkAId}, #{clerkBId}) "
|
||||
+ " AND o.order_end_time >= #{startTime} "
|
||||
+ " AND o.order_end_time <= #{endTime} "
|
||||
+ "GROUP BY o.purchaser_by, u.nickname "
|
||||
+ "ORDER BY amount DESC "
|
||||
+ "LIMIT #{limit}"
|
||||
+ "</script>")
|
||||
List<WxPkContributorDto> selectPkContributors(
|
||||
@Param("tenantId") String tenantId,
|
||||
@Param("clerkAId") String clerkAId,
|
||||
@Param("clerkBId") String clerkBId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime,
|
||||
@Param("orderStatus") String orderStatus,
|
||||
@Param("minAmount") BigDecimal minAmount,
|
||||
@Param("limit") int limit);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.starry.admin.modules.order.service.IOrderLifecycleService;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
|
||||
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator;
|
||||
import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService;
|
||||
import com.starry.admin.modules.pk.event.PkContributionEvent;
|
||||
import com.starry.admin.modules.shop.module.constant.CouponUseState;
|
||||
import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity;
|
||||
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
|
||||
@@ -504,6 +505,17 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
|
||||
if (shouldNotify) {
|
||||
notificationSender.sendOrderFinishMessageAsync(latest);
|
||||
}
|
||||
|
||||
if (OrderStatus.COMPLETED.getCode().equals(latest.getOrderStatus())
|
||||
&& latest.getFinalAmount() != null
|
||||
&& latest.getFinalAmount().compareTo(BigDecimal.ZERO) > 0
|
||||
&& StrUtil.isNotBlank(latest.getAcceptBy())) {
|
||||
LocalDateTime contributionTime =
|
||||
latest.getOrderEndTime() != null ? latest.getOrderEndTime() : endTime;
|
||||
applicationEventPublisher.publishEvent(
|
||||
PkContributionEvent.orderContribution(latest.getId(), latest.getAcceptBy(),
|
||||
latest.getFinalAmount(), contributionTime));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.starry.admin.modules.pk.constants;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public final class PkWxQueryConstants {
|
||||
|
||||
public static final int CONTRIBUTOR_LIMIT = 10;
|
||||
public static final int HISTORY_LIMIT = 10;
|
||||
public static final int CLERK_HISTORY_PAGE_NUM = 1;
|
||||
public static final int CLERK_HISTORY_PAGE_SIZE = 10;
|
||||
public static final int CLERK_HISTORY_MIN_PAGE = 1;
|
||||
public static final int CLERK_SCHEDULE_DEFAULT_LIMIT = 3;
|
||||
public static final int CLERK_SCHEDULE_MIN_LIMIT = 1;
|
||||
public static final int CLERK_SCHEDULE_MAX_LIMIT = 20;
|
||||
public static final int TOP_CONTRIBUTOR_LIMIT = 1;
|
||||
public static final int WIN_RATE_SCALE = 2;
|
||||
public static final String PERCENT_SUFFIX = "%";
|
||||
public static final BigDecimal MIN_CONTRIBUTION_AMOUNT = BigDecimal.ZERO;
|
||||
public static final BigDecimal WIN_RATE_MULTIPLIER = new BigDecimal("100");
|
||||
|
||||
private PkWxQueryConstants() {
|
||||
throw new IllegalStateException("Utility class");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.starry.admin.modules.pk.dto;
|
||||
|
||||
/**
|
||||
* 通过 WebSocket 推送的 PK 事件消息。
|
||||
*/
|
||||
public class PkEventMessage<T> {
|
||||
|
||||
private PkEventType type;
|
||||
private String pkId;
|
||||
private long timestamp;
|
||||
private T payload;
|
||||
|
||||
public static <T> PkEventMessage<T> of(PkEventType type, String pkId, T payload, long timestamp) {
|
||||
PkEventMessage<T> message = new PkEventMessage<>();
|
||||
message.setType(type);
|
||||
message.setPkId(pkId);
|
||||
message.setPayload(payload);
|
||||
message.setTimestamp(timestamp);
|
||||
return message;
|
||||
}
|
||||
|
||||
public PkEventType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(PkEventType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getPkId() {
|
||||
return pkId;
|
||||
}
|
||||
|
||||
public void setPkId(String pkId) {
|
||||
this.pkId = pkId;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public void setTimestamp(long timestamp) {
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
public T getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
public void setPayload(T payload) {
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.starry.admin.modules.pk.dto;
|
||||
|
||||
/**
|
||||
* PK WebSocket 事件类型。
|
||||
*/
|
||||
public enum PkEventType {
|
||||
SCORE_UPDATE,
|
||||
STATE_CHANGE
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.starry.admin.modules.pk.dto;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@ApiModel(value = "PkScoreBoardDto", description = "店员 PK 实时比分")
|
||||
public class PkScoreBoardDto {
|
||||
|
||||
@ApiModelProperty(value = "PK ID", required = true)
|
||||
private String pkId;
|
||||
|
||||
@ApiModelProperty(value = "店员A ID", required = true)
|
||||
private String clerkAId;
|
||||
|
||||
@ApiModelProperty(value = "店员B ID", required = true)
|
||||
private String clerkBId;
|
||||
|
||||
@ApiModelProperty(value = "店员A得分", required = true)
|
||||
private BigDecimal clerkAScore;
|
||||
|
||||
@ApiModelProperty(value = "店员B得分", required = true)
|
||||
private BigDecimal clerkBScore;
|
||||
|
||||
@ApiModelProperty(value = "店员A订单数", required = true)
|
||||
private int clerkAOrderCount;
|
||||
|
||||
@ApiModelProperty(value = "店员B订单数", required = true)
|
||||
private int clerkBOrderCount;
|
||||
|
||||
@ApiModelProperty(value = "PK 剩余秒数", required = true)
|
||||
private long remainingSeconds;
|
||||
|
||||
public String getPkId() {
|
||||
return pkId;
|
||||
}
|
||||
|
||||
public void setPkId(String pkId) {
|
||||
this.pkId = pkId;
|
||||
}
|
||||
|
||||
public String getClerkAId() {
|
||||
return clerkAId;
|
||||
}
|
||||
|
||||
public void setClerkAId(String clerkAId) {
|
||||
this.clerkAId = clerkAId;
|
||||
}
|
||||
|
||||
public String getClerkBId() {
|
||||
return clerkBId;
|
||||
}
|
||||
|
||||
public void setClerkBId(String clerkBId) {
|
||||
this.clerkBId = clerkBId;
|
||||
}
|
||||
|
||||
public BigDecimal getClerkAScore() {
|
||||
return clerkAScore;
|
||||
}
|
||||
|
||||
public void setClerkAScore(BigDecimal clerkAScore) {
|
||||
this.clerkAScore = clerkAScore;
|
||||
}
|
||||
|
||||
public BigDecimal getClerkBScore() {
|
||||
return clerkBScore;
|
||||
}
|
||||
|
||||
public void setClerkBScore(BigDecimal clerkBScore) {
|
||||
this.clerkBScore = clerkBScore;
|
||||
}
|
||||
|
||||
public int getClerkAOrderCount() {
|
||||
return clerkAOrderCount;
|
||||
}
|
||||
|
||||
public void setClerkAOrderCount(int clerkAOrderCount) {
|
||||
this.clerkAOrderCount = clerkAOrderCount;
|
||||
}
|
||||
|
||||
public int getClerkBOrderCount() {
|
||||
return clerkBOrderCount;
|
||||
}
|
||||
|
||||
public void setClerkBOrderCount(int clerkBOrderCount) {
|
||||
this.clerkBOrderCount = clerkBOrderCount;
|
||||
}
|
||||
|
||||
public long getRemainingSeconds() {
|
||||
return remainingSeconds;
|
||||
}
|
||||
|
||||
public void setRemainingSeconds(long remainingSeconds) {
|
||||
this.remainingSeconds = remainingSeconds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.starry.admin.modules.pk.dto;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
@ApiModel(value = "PlayClerkPkForceStartRequest", description = "强制开始PK请求")
|
||||
@Data
|
||||
public class PlayClerkPkForceStartRequest {
|
||||
|
||||
@ApiModelProperty(value = "店员A ID", required = true)
|
||||
private String clerkAId;
|
||||
|
||||
@ApiModelProperty(value = "店员B ID", required = true)
|
||||
private String clerkBId;
|
||||
|
||||
@ApiModelProperty(value = "持续分钟数", required = true)
|
||||
private Integer durationMinutes;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.starry.admin.modules.pk.dto;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class WxPkClerkHistoryPageDto {
|
||||
private List<WxPkHistoryDto> items = new ArrayList<>();
|
||||
private WxPkClerkHistorySummaryDto summary = new WxPkClerkHistorySummaryDto();
|
||||
private long totalCount;
|
||||
private int pageNum;
|
||||
private int pageSize;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.starry.admin.modules.pk.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class WxPkClerkHistorySummaryDto {
|
||||
private static final String ZERO_PERCENT = "0%";
|
||||
|
||||
private long winCount;
|
||||
private long totalCount;
|
||||
private String winRate = ZERO_PERCENT;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.starry.admin.modules.pk.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class WxPkContributorDto {
|
||||
private static final String EMPTY_TEXT = "";
|
||||
|
||||
private String userId = EMPTY_TEXT;
|
||||
private String nickname = EMPTY_TEXT;
|
||||
private BigDecimal amount = BigDecimal.ZERO;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.starry.admin.modules.pk.dto;
|
||||
|
||||
import com.starry.admin.modules.pk.enums.PkWxState;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class WxPkDetailDto {
|
||||
private static final String EMPTY_TEXT = "";
|
||||
private static final long ZERO_SECONDS = 0L;
|
||||
private static final Date EPOCH_DATE = Date.from(Instant.EPOCH);
|
||||
|
||||
private String id = EMPTY_TEXT;
|
||||
private String state = EMPTY_TEXT;
|
||||
private String clerkAId = EMPTY_TEXT;
|
||||
private String clerkBId = EMPTY_TEXT;
|
||||
private String clerkAName = EMPTY_TEXT;
|
||||
private String clerkBName = EMPTY_TEXT;
|
||||
private String clerkAAvatar = EMPTY_TEXT;
|
||||
private String clerkBAvatar = EMPTY_TEXT;
|
||||
private BigDecimal clerkAScore = BigDecimal.ZERO;
|
||||
private BigDecimal clerkBScore = BigDecimal.ZERO;
|
||||
private int clerkAOrderCount = 0;
|
||||
private int clerkBOrderCount = 0;
|
||||
private long remainingSeconds = ZERO_SECONDS;
|
||||
private Date pkBeginTime = EPOCH_DATE;
|
||||
private Date pkEndTime = EPOCH_DATE;
|
||||
private List<WxPkContributorDto> contributors = new ArrayList<>();
|
||||
private List<WxPkHistoryDto> history = new ArrayList<>();
|
||||
|
||||
public static WxPkDetailDto inactive() {
|
||||
WxPkDetailDto dto = new WxPkDetailDto();
|
||||
dto.setState(PkWxState.INACTIVE.getValue());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.starry.admin.modules.pk.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class WxPkHistoryDto {
|
||||
private static final String EMPTY_TEXT = "";
|
||||
private static final Date EPOCH_DATE = Date.from(Instant.EPOCH);
|
||||
|
||||
private String id = EMPTY_TEXT;
|
||||
private String clerkAId = EMPTY_TEXT;
|
||||
private String clerkBId = EMPTY_TEXT;
|
||||
private String winnerClerkId = EMPTY_TEXT;
|
||||
private String clerkAName = EMPTY_TEXT;
|
||||
private String clerkBName = EMPTY_TEXT;
|
||||
private BigDecimal clerkAScore = BigDecimal.ZERO;
|
||||
private BigDecimal clerkBScore = BigDecimal.ZERO;
|
||||
private Date pkBeginTime = EPOCH_DATE;
|
||||
private String topContributorName = EMPTY_TEXT;
|
||||
private BigDecimal topContributorAmount = BigDecimal.ZERO;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.starry.admin.modules.pk.dto;
|
||||
|
||||
import com.starry.admin.modules.pk.enums.PkWxState;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class WxPkLiveDto {
|
||||
private static final String EMPTY_TEXT = "";
|
||||
private static final long ZERO_SECONDS = 0L;
|
||||
|
||||
private String id = EMPTY_TEXT;
|
||||
private String state = EMPTY_TEXT;
|
||||
private String clerkAId = EMPTY_TEXT;
|
||||
private String clerkBId = EMPTY_TEXT;
|
||||
private String clerkAName = EMPTY_TEXT;
|
||||
private String clerkBName = EMPTY_TEXT;
|
||||
private String clerkAAvatar = EMPTY_TEXT;
|
||||
private String clerkBAvatar = EMPTY_TEXT;
|
||||
private BigDecimal clerkAScore = BigDecimal.ZERO;
|
||||
private BigDecimal clerkBScore = BigDecimal.ZERO;
|
||||
private int clerkAOrderCount = 0;
|
||||
private int clerkBOrderCount = 0;
|
||||
private long remainingSeconds = ZERO_SECONDS;
|
||||
private long pkEndEpochSeconds = ZERO_SECONDS;
|
||||
private long serverEpochSeconds = ZERO_SECONDS;
|
||||
|
||||
public static WxPkLiveDto inactive() {
|
||||
WxPkLiveDto dto = new WxPkLiveDto();
|
||||
dto.setState(PkWxState.INACTIVE.getValue());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.starry.admin.modules.pk.dto;
|
||||
|
||||
import com.starry.admin.modules.pk.enums.PkWxState;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class WxPkUpcomingDto {
|
||||
private static final String EMPTY_TEXT = "";
|
||||
private static final Date EPOCH_DATE = Date.from(Instant.EPOCH);
|
||||
|
||||
private String id = EMPTY_TEXT;
|
||||
private String state = EMPTY_TEXT;
|
||||
private String clerkAId = EMPTY_TEXT;
|
||||
private String clerkBId = EMPTY_TEXT;
|
||||
private String clerkAName = EMPTY_TEXT;
|
||||
private String clerkBName = EMPTY_TEXT;
|
||||
private String clerkAAvatar = EMPTY_TEXT;
|
||||
private String clerkBAvatar = EMPTY_TEXT;
|
||||
private Date pkBeginTime = EPOCH_DATE;
|
||||
|
||||
public static WxPkUpcomingDto inactive() {
|
||||
WxPkUpcomingDto dto = new WxPkUpcomingDto();
|
||||
dto.setState(PkWxState.INACTIVE.getValue());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.starry.admin.modules.pk.enums;
|
||||
|
||||
public enum PkLifecycleErrorCode {
|
||||
REQUEST_INVALID("PK手动开始参数非法"),
|
||||
CLERK_CONFLICT("店员排期冲突"),
|
||||
TENANT_MISSING("租户ID缺失");
|
||||
|
||||
private final String message;
|
||||
|
||||
PkLifecycleErrorCode(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.starry.admin.modules.pk.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@AllArgsConstructor
|
||||
public enum PkWxState {
|
||||
ACTIVE("ACTIVE", "进行中"),
|
||||
UPCOMING("UPCOMING", "即将开始"),
|
||||
INACTIVE("INACTIVE", "无进行中PK");
|
||||
|
||||
@Getter
|
||||
private final String value;
|
||||
@Getter
|
||||
private final String desc;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.starry.admin.modules.pk.event;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 店员在 PK 期间产生的一次贡献事件。
|
||||
*/
|
||||
public final class PkContributionEvent {
|
||||
|
||||
/**
|
||||
* 贡献来源的业务唯一ID,例如订单ID、礼物记录ID等。
|
||||
*/
|
||||
private final String referenceId;
|
||||
|
||||
private final String clerkId;
|
||||
private final BigDecimal amount;
|
||||
private final PkContributionSource source;
|
||||
private final LocalDateTime occurredAt;
|
||||
|
||||
private PkContributionEvent(String referenceId, String clerkId, BigDecimal amount, PkContributionSource source,
|
||||
LocalDateTime occurredAt) {
|
||||
this.referenceId = Objects.requireNonNull(referenceId, "referenceId cannot be null");
|
||||
this.clerkId = Objects.requireNonNull(clerkId, "clerkId cannot be null");
|
||||
this.amount = Objects.requireNonNull(amount, "amount cannot be null");
|
||||
this.source = Objects.requireNonNull(source, "source cannot be null");
|
||||
this.occurredAt = Objects.requireNonNull(occurredAt, "occurredAt cannot be null");
|
||||
if (this.referenceId.isEmpty()) {
|
||||
throw new IllegalArgumentException("referenceId cannot be empty");
|
||||
}
|
||||
if (this.clerkId.isEmpty()) {
|
||||
throw new IllegalArgumentException("clerkId cannot be empty");
|
||||
}
|
||||
if (this.amount.compareTo(BigDecimal.ZERO) < 0) {
|
||||
throw new IllegalArgumentException("amount cannot be negative");
|
||||
}
|
||||
}
|
||||
|
||||
public static PkContributionEvent orderContribution(String orderId, String clerkId, BigDecimal amount,
|
||||
LocalDateTime occurredAt) {
|
||||
return new PkContributionEvent(orderId, clerkId, amount, PkContributionSource.ORDER, occurredAt);
|
||||
}
|
||||
|
||||
public static PkContributionEvent giftContribution(String giftRecordId, String clerkId, BigDecimal amount,
|
||||
LocalDateTime occurredAt) {
|
||||
return new PkContributionEvent(giftRecordId, clerkId, amount, PkContributionSource.GIFT, occurredAt);
|
||||
}
|
||||
|
||||
public static PkContributionEvent rechargeContribution(String rechargeRecordId, String clerkId, BigDecimal amount,
|
||||
LocalDateTime occurredAt) {
|
||||
return new PkContributionEvent(rechargeRecordId, clerkId, amount, PkContributionSource.RECHARGE, occurredAt);
|
||||
}
|
||||
|
||||
public String getReferenceId() {
|
||||
return referenceId;
|
||||
}
|
||||
|
||||
public String getClerkId() {
|
||||
return clerkId;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public PkContributionSource getSource() {
|
||||
return source;
|
||||
}
|
||||
|
||||
public LocalDateTime getOccurredAt() {
|
||||
return occurredAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.starry.admin.modules.pk.event;
|
||||
|
||||
/**
|
||||
* PK 贡献来源类型。
|
||||
*/
|
||||
public enum PkContributionSource {
|
||||
ORDER,
|
||||
GIFT,
|
||||
RECHARGE
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.starry.admin.modules.pk.event;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 某一场 PK 的比分发生变化。
|
||||
*/
|
||||
public final class PkScoreChangedEvent {
|
||||
|
||||
private final String pkId;
|
||||
|
||||
public PkScoreChangedEvent(String pkId) {
|
||||
this.pkId = Objects.requireNonNull(pkId, "pkId cannot be null");
|
||||
if (this.pkId.isEmpty()) {
|
||||
throw new IllegalArgumentException("pkId cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
public String getPkId() {
|
||||
return pkId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.starry.admin.modules.pk.listener;
|
||||
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
|
||||
import com.starry.admin.modules.pk.event.PkContributionEvent;
|
||||
import com.starry.admin.modules.pk.event.PkScoreChangedEvent;
|
||||
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 监听订单/礼物/充值产生的 PK 贡献事件,更新 Redis 中的比分。
|
||||
*/
|
||||
@Component
|
||||
public class PkContributionListener {
|
||||
|
||||
private static final long SINGLE_CONTRIBUTION_COUNT = 1L;
|
||||
|
||||
@Resource
|
||||
private IPlayClerkPkService clerkPkService;
|
||||
|
||||
@Resource
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@Resource
|
||||
private ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@EventListener
|
||||
public void onContribution(PkContributionEvent event) {
|
||||
if (!acquireDedup(event)) {
|
||||
return;
|
||||
}
|
||||
Optional<PlayClerkPkEntity> pkOptional =
|
||||
clerkPkService.findActivePkForClerk(event.getClerkId(), event.getOccurredAt());
|
||||
if (!pkOptional.isPresent()) {
|
||||
return;
|
||||
}
|
||||
PlayClerkPkEntity pk = pkOptional.get();
|
||||
updateRedisScore(pk, event);
|
||||
applicationEventPublisher.publishEvent(new PkScoreChangedEvent(pk.getId()));
|
||||
}
|
||||
|
||||
private boolean acquireDedup(PkContributionEvent event) {
|
||||
String dedupKey =
|
||||
PkRedisKeyConstants.contributionDedupKey(event.getSource().name(), event.getReferenceId());
|
||||
Boolean firstSeen = stringRedisTemplate.opsForValue()
|
||||
.setIfAbsent(dedupKey, "1", PkRedisKeyConstants.CONTRIBUTION_DEDUP_TTL_SECONDS, TimeUnit.SECONDS);
|
||||
return firstSeen == null || firstSeen;
|
||||
}
|
||||
|
||||
private void updateRedisScore(PlayClerkPkEntity pk, PkContributionEvent event) {
|
||||
String pkId = pk.getId();
|
||||
if (pkId == null || pkId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
boolean isClerkA = event.getClerkId().equals(pk.getClerkA());
|
||||
String scoreField = isClerkA ? PkRedisKeyConstants.FIELD_CLERK_A_SCORE : PkRedisKeyConstants.FIELD_CLERK_B_SCORE;
|
||||
String countField =
|
||||
isClerkA ? PkRedisKeyConstants.FIELD_CLERK_A_ORDER_COUNT : PkRedisKeyConstants.FIELD_CLERK_B_ORDER_COUNT;
|
||||
|
||||
String scoreKey = PkRedisKeyConstants.scoreKey(pkId);
|
||||
stringRedisTemplate.opsForHash().increment(scoreKey, scoreField, event.getAmount().doubleValue());
|
||||
stringRedisTemplate.opsForHash().increment(scoreKey, countField, SINGLE_CONTRIBUTION_COUNT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.starry.admin.modules.pk.listener;
|
||||
|
||||
import com.starry.admin.modules.pk.dto.PkEventMessage;
|
||||
import com.starry.admin.modules.pk.dto.PkEventType;
|
||||
import com.starry.admin.modules.pk.dto.PkScoreBoardDto;
|
||||
import com.starry.admin.modules.pk.event.PkScoreChangedEvent;
|
||||
import com.starry.admin.modules.pk.service.IPkScoreboardService;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 监听比分变化事件,通过 WebSocket 将最新比分推送给订阅方。
|
||||
*/
|
||||
@Component
|
||||
public class PkScoreChangedListener {
|
||||
|
||||
private static final String PK_TOPIC_PREFIX = "/topic/pk/";
|
||||
|
||||
@Resource
|
||||
private IPkScoreboardService pkScoreboardService;
|
||||
|
||||
@Resource
|
||||
private SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
@EventListener
|
||||
public void onScoreChanged(PkScoreChangedEvent event) {
|
||||
PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(event.getPkId());
|
||||
PkEventMessage<PkScoreBoardDto> message = PkEventMessage.of(
|
||||
PkEventType.SCORE_UPDATE,
|
||||
event.getPkId(),
|
||||
scoreboard,
|
||||
System.currentTimeMillis());
|
||||
messagingTemplate.convertAndSend(PK_TOPIC_PREFIX + event.getPkId(), message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.starry.admin.modules.pk.redis;
|
||||
|
||||
/**
|
||||
* PK 相关 Redis key 常量。
|
||||
*/
|
||||
public final class PkRedisKeyConstants {
|
||||
|
||||
private static final String SCORE_HASH_PREFIX = "pk:";
|
||||
private static final String SCORE_HASH_SUFFIX = ":score";
|
||||
private static final String DEDUP_KEY_PREFIX = "pk:dedup:";
|
||||
private static final String UPCOMING_PREFIX = "pk:upcoming:";
|
||||
private static final String START_SCHEDULE_PREFIX = "pk:scheduler:start:";
|
||||
private static final String START_LOCK_PREFIX = "pk:scheduler:start:lock:";
|
||||
private static final String FINISH_SCHEDULE_PREFIX = "pk:scheduler:finish:";
|
||||
private static final String FINISH_RETRY_PREFIX = "pk:scheduler:finish:retry:";
|
||||
private static final String FINISH_FAILED_PREFIX = "pk:scheduler:finish:failed:";
|
||||
private static final String FINISH_LOCK_PREFIX = "pk:scheduler:finish:lock:";
|
||||
|
||||
/**
|
||||
* 贡献幂等记录的存活时间(秒)。
|
||||
*/
|
||||
public static final long CONTRIBUTION_DEDUP_TTL_SECONDS = 3600L;
|
||||
public static final long UPCOMING_REMINDER_TTL_SECONDS = 7200L;
|
||||
|
||||
public static final String FIELD_CLERK_A_SCORE = "clerk_a_score";
|
||||
public static final String FIELD_CLERK_B_SCORE = "clerk_b_score";
|
||||
public static final String FIELD_CLERK_A_ORDER_COUNT = "clerk_a_order_count";
|
||||
public static final String FIELD_CLERK_B_ORDER_COUNT = "clerk_b_order_count";
|
||||
|
||||
private PkRedisKeyConstants() {
|
||||
}
|
||||
|
||||
public static String scoreKey(String pkId) {
|
||||
return SCORE_HASH_PREFIX + pkId + SCORE_HASH_SUFFIX;
|
||||
}
|
||||
|
||||
public static String contributionDedupKey(String sourceCode, String referenceId) {
|
||||
return DEDUP_KEY_PREFIX + sourceCode + ":" + referenceId;
|
||||
}
|
||||
|
||||
public static String upcomingKey(String tenantId) {
|
||||
return UPCOMING_PREFIX + tenantId;
|
||||
}
|
||||
|
||||
public static String startScheduleKey(String tenantId) {
|
||||
return START_SCHEDULE_PREFIX + tenantId;
|
||||
}
|
||||
|
||||
public static String startLockKey(String tenantId) {
|
||||
return START_LOCK_PREFIX + tenantId;
|
||||
}
|
||||
|
||||
public static String finishScheduleKey(String tenantId) {
|
||||
return FINISH_SCHEDULE_PREFIX + tenantId;
|
||||
}
|
||||
|
||||
public static String finishRetryKey(String tenantId) {
|
||||
return FINISH_RETRY_PREFIX + tenantId;
|
||||
}
|
||||
|
||||
public static String finishFailedKey(String tenantId) {
|
||||
return FINISH_FAILED_PREFIX + tenantId;
|
||||
}
|
||||
|
||||
public static String finishLockKey(String tenantId) {
|
||||
return FINISH_LOCK_PREFIX + tenantId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.starry.admin.modules.pk.reminder.constants;
|
||||
|
||||
public final class PkReminderConstants {
|
||||
|
||||
public static final long SCAN_INTERVAL_MILLIS = 300000L;
|
||||
public static final long UPCOMING_WINDOW_MINUTES = 60L;
|
||||
|
||||
private PkReminderConstants() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.starry.admin.modules.pk.reminder.task;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.starry.admin.modules.clerk.mapper.PlayClerkPkMapper;
|
||||
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
|
||||
import com.starry.admin.modules.pk.reminder.constants.PkReminderConstants;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class ClerkPkUpcomingReminderJob {
|
||||
|
||||
private static final double SCORE_MIN = Double.NEGATIVE_INFINITY;
|
||||
|
||||
private final PlayClerkPkMapper clerkPkMapper;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
public ClerkPkUpcomingReminderJob(PlayClerkPkMapper clerkPkMapper, StringRedisTemplate stringRedisTemplate) {
|
||||
this.clerkPkMapper = clerkPkMapper;
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = PkReminderConstants.SCAN_INTERVAL_MILLIS)
|
||||
public void refreshUpcomingPkReminders() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime windowEnd = now.plusMinutes(PkReminderConstants.UPCOMING_WINDOW_MINUTES);
|
||||
Date begin = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
|
||||
Date end = Date.from(windowEnd.atZone(ZoneId.systemDefault()).toInstant());
|
||||
|
||||
List<PlayClerkPkEntity> upcoming = clerkPkMapper.selectUpcomingByStatus(
|
||||
ClerkPkEnum.TO_BE_STARTED.name(),
|
||||
begin,
|
||||
end);
|
||||
if (upcoming.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, List<PlayClerkPkEntity>> byTenant = upcoming.stream()
|
||||
.filter(pk -> StrUtil.isNotBlank(pk.getTenantId()))
|
||||
.collect(Collectors.groupingBy(PlayClerkPkEntity::getTenantId));
|
||||
long nowEpochSeconds = now.atZone(ZoneId.systemDefault()).toEpochSecond();
|
||||
for (Map.Entry<String, List<PlayClerkPkEntity>> entry : byTenant.entrySet()) {
|
||||
String key = PkRedisKeyConstants.upcomingKey(entry.getKey());
|
||||
stringRedisTemplate.opsForZSet().removeRangeByScore(key, SCORE_MIN, nowEpochSeconds - 1);
|
||||
for (PlayClerkPkEntity pk : entry.getValue()) {
|
||||
if (pk.getPkBeginTime() == null || pk.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
long score = pk.getPkBeginTime().toInstant().getEpochSecond();
|
||||
stringRedisTemplate.opsForZSet().add(key, pk.getId(), score);
|
||||
}
|
||||
stringRedisTemplate.expire(key, PkRedisKeyConstants.UPCOMING_REMINDER_TTL_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.starry.admin.modules.pk.scheduler.constants;
|
||||
|
||||
public final class PkSchedulerConstants {
|
||||
|
||||
public static final long START_SCAN_INTERVAL_MILLIS = 1000L;
|
||||
public static final long FINISH_SCAN_INTERVAL_MILLIS = 1000L;
|
||||
public static final long FALLBACK_SCAN_INTERVAL_MILLIS = 300000L;
|
||||
|
||||
public static final long START_LOCK_TTL_MILLIS = 5000L;
|
||||
public static final long FINISH_LOCK_TTL_MILLIS = 5000L;
|
||||
|
||||
public static final int FINISH_RETRY_MAX_ATTEMPTS = 3;
|
||||
public static final int[] FINISH_RETRY_BACKOFF_SECONDS = {5, 10, 20};
|
||||
|
||||
public static final int START_RETRY_DELAY_SECONDS = 5;
|
||||
|
||||
private PkSchedulerConstants() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.starry.admin.modules.pk.scheduler.task;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.pk.enums.PkLifecycleErrorCode;
|
||||
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
|
||||
import com.starry.admin.modules.pk.scheduler.constants.PkSchedulerConstants;
|
||||
import com.starry.admin.modules.pk.service.ClerkPkLifecycleService;
|
||||
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
|
||||
import com.starry.admin.modules.system.service.ISysTenantService;
|
||||
import com.starry.admin.utils.TenantScope;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PkFinishSchedulerJob {
|
||||
|
||||
private static final double SCORE_MIN = Double.NEGATIVE_INFINITY;
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final ClerkPkLifecycleService clerkPkLifecycleService;
|
||||
private final ISysTenantService sysTenantService;
|
||||
|
||||
public PkFinishSchedulerJob(StringRedisTemplate stringRedisTemplate,
|
||||
ClerkPkLifecycleService clerkPkLifecycleService,
|
||||
ISysTenantService sysTenantService) {
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.clerkPkLifecycleService = clerkPkLifecycleService;
|
||||
this.sysTenantService = sysTenantService;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = PkSchedulerConstants.FINISH_SCAN_INTERVAL_MILLIS)
|
||||
public void scanFinishSchedule() {
|
||||
List<SysTenantEntity> tenantEntities = sysTenantService.listAll();
|
||||
if (tenantEntities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long nowEpochSeconds = Instant.now().getEpochSecond();
|
||||
for (SysTenantEntity tenantEntity : tenantEntities) {
|
||||
String tenantId = tenantEntity.getTenantId();
|
||||
if (StrUtil.isBlank(tenantId)) {
|
||||
log.warn("PK结算调度跳过空租户, errorCode={}", PkLifecycleErrorCode.TENANT_MISSING.name());
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
handleTenant(tenantId, nowEpochSeconds);
|
||||
} catch (Exception ex) {
|
||||
log.error("PK结算调度失败, tenantId={}", tenantId, ex);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleTenant(String tenantId, long nowEpochSeconds) {
|
||||
String lockKey = PkRedisKeyConstants.finishLockKey(tenantId);
|
||||
String lockValue = IdUtils.getUuid();
|
||||
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(
|
||||
lockKey,
|
||||
lockValue,
|
||||
PkSchedulerConstants.FINISH_LOCK_TTL_MILLIS,
|
||||
TimeUnit.MILLISECONDS);
|
||||
if (!Boolean.TRUE.equals(locked)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
try (TenantScope scope = TenantScope.use(tenantId)) {
|
||||
String scheduleKey = PkRedisKeyConstants.finishScheduleKey(tenantId);
|
||||
Set<String> duePkIds = stringRedisTemplate.opsForZSet()
|
||||
.rangeByScore(scheduleKey, SCORE_MIN, nowEpochSeconds);
|
||||
if (duePkIds == null || duePkIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (String pkId : duePkIds) {
|
||||
processFinish(scheduleKey, tenantId, pkId, nowEpochSeconds);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
releaseLock(lockKey, lockValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void processFinish(String scheduleKey, String tenantId, String pkId, long nowEpochSeconds) {
|
||||
if (StrUtil.isBlank(pkId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
clerkPkLifecycleService.finishPk(pkId);
|
||||
clearRetry(tenantId, pkId);
|
||||
removeFailed(tenantId, pkId);
|
||||
stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId);
|
||||
} catch (Exception ex) {
|
||||
handleRetry(scheduleKey, tenantId, pkId, nowEpochSeconds, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleRetry(String scheduleKey, String tenantId, String pkId, long nowEpochSeconds, Exception ex) {
|
||||
String retryKey = PkRedisKeyConstants.finishRetryKey(tenantId);
|
||||
Long attempt = stringRedisTemplate.opsForHash().increment(retryKey, pkId, 1L);
|
||||
int retryCount = attempt == null ? 1 : attempt.intValue();
|
||||
if (retryCount >= PkSchedulerConstants.FINISH_RETRY_MAX_ATTEMPTS) {
|
||||
String failedKey = PkRedisKeyConstants.finishFailedKey(tenantId);
|
||||
stringRedisTemplate.opsForZSet().add(failedKey, pkId, nowEpochSeconds);
|
||||
stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId);
|
||||
clearRetry(tenantId, pkId);
|
||||
log.error("PK 自动结算失败超过重试上限, pkId={}, retryCount={}", pkId, retryCount, ex);
|
||||
return;
|
||||
}
|
||||
long backoffSeconds = resolveBackoffSeconds(retryCount);
|
||||
long nextScore = nowEpochSeconds + backoffSeconds;
|
||||
stringRedisTemplate.opsForZSet().add(scheduleKey, pkId, nextScore);
|
||||
log.warn("PK 自动结算失败, pkId={}, retryCount={}, nextScore={}", pkId, retryCount, nextScore, ex);
|
||||
}
|
||||
|
||||
private long resolveBackoffSeconds(int retryCount) {
|
||||
int[] backoffs = PkSchedulerConstants.FINISH_RETRY_BACKOFF_SECONDS;
|
||||
int index = Math.min(retryCount, backoffs.length) - 1;
|
||||
return backoffs[index];
|
||||
}
|
||||
|
||||
private void clearRetry(String tenantId, String pkId) {
|
||||
String retryKey = PkRedisKeyConstants.finishRetryKey(tenantId);
|
||||
stringRedisTemplate.opsForHash().delete(retryKey, pkId);
|
||||
}
|
||||
|
||||
private void removeFailed(String tenantId, String pkId) {
|
||||
String failedKey = PkRedisKeyConstants.finishFailedKey(tenantId);
|
||||
stringRedisTemplate.opsForZSet().remove(failedKey, pkId);
|
||||
}
|
||||
|
||||
private void releaseLock(String lockKey, String lockValue) {
|
||||
String currentValue = stringRedisTemplate.opsForValue().get(lockKey);
|
||||
if (lockValue.equals(currentValue)) {
|
||||
stringRedisTemplate.delete(lockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.starry.admin.modules.pk.scheduler.task;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
|
||||
import com.starry.admin.modules.pk.enums.PkLifecycleErrorCode;
|
||||
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
|
||||
import com.starry.admin.modules.pk.scheduler.constants.PkSchedulerConstants;
|
||||
import com.starry.admin.modules.pk.service.ClerkPkLifecycleService;
|
||||
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
|
||||
import com.starry.admin.modules.system.service.ISysTenantService;
|
||||
import com.starry.admin.utils.TenantScope;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PkStartSchedulerJob {
|
||||
|
||||
private static final double SCORE_MIN = Double.NEGATIVE_INFINITY;
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final ClerkPkLifecycleService clerkPkLifecycleService;
|
||||
private final IPlayClerkPkService clerkPkService;
|
||||
private final ISysTenantService sysTenantService;
|
||||
|
||||
public PkStartSchedulerJob(StringRedisTemplate stringRedisTemplate,
|
||||
ClerkPkLifecycleService clerkPkLifecycleService,
|
||||
IPlayClerkPkService clerkPkService,
|
||||
ISysTenantService sysTenantService) {
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.clerkPkLifecycleService = clerkPkLifecycleService;
|
||||
this.clerkPkService = clerkPkService;
|
||||
this.sysTenantService = sysTenantService;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = PkSchedulerConstants.START_SCAN_INTERVAL_MILLIS)
|
||||
public void scanStartSchedule() {
|
||||
List<SysTenantEntity> tenantEntities = sysTenantService.listAll();
|
||||
if (tenantEntities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long nowEpochSeconds = Instant.now().getEpochSecond();
|
||||
for (SysTenantEntity tenantEntity : tenantEntities) {
|
||||
String tenantId = tenantEntity.getTenantId();
|
||||
if (StrUtil.isBlank(tenantId)) {
|
||||
log.warn("PK开始调度跳过空租户, errorCode={}", PkLifecycleErrorCode.TENANT_MISSING.name());
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
handleTenant(tenantId, nowEpochSeconds);
|
||||
} catch (Exception ex) {
|
||||
log.error("PK开始调度失败, tenantId={}", tenantId, ex);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleTenant(String tenantId, long nowEpochSeconds) {
|
||||
String lockKey = PkRedisKeyConstants.startLockKey(tenantId);
|
||||
String lockValue = IdUtils.getUuid();
|
||||
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(
|
||||
lockKey,
|
||||
lockValue,
|
||||
PkSchedulerConstants.START_LOCK_TTL_MILLIS,
|
||||
TimeUnit.MILLISECONDS);
|
||||
if (!Boolean.TRUE.equals(locked)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
try (TenantScope scope = TenantScope.use(tenantId)) {
|
||||
String scheduleKey = PkRedisKeyConstants.startScheduleKey(tenantId);
|
||||
Set<String> duePkIds = stringRedisTemplate.opsForZSet()
|
||||
.rangeByScore(scheduleKey, SCORE_MIN, nowEpochSeconds);
|
||||
if (duePkIds == null || duePkIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (String pkId : duePkIds) {
|
||||
processStart(scheduleKey, pkId, nowEpochSeconds);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
releaseLock(lockKey, lockValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void processStart(String scheduleKey, String pkId, long nowEpochSeconds) {
|
||||
if (StrUtil.isBlank(pkId)) {
|
||||
return;
|
||||
}
|
||||
PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId);
|
||||
if (pk == null) {
|
||||
stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId);
|
||||
return;
|
||||
}
|
||||
if (!ClerkPkEnum.TO_BE_STARTED.name().equals(pk.getStatus())) {
|
||||
stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId);
|
||||
return;
|
||||
}
|
||||
if (pk.getPkBeginTime() == null) {
|
||||
stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId);
|
||||
return;
|
||||
}
|
||||
long beginEpochSeconds = pk.getPkBeginTime().toInstant().getEpochSecond();
|
||||
if (beginEpochSeconds > nowEpochSeconds) {
|
||||
stringRedisTemplate.opsForZSet().add(scheduleKey, pkId, beginEpochSeconds);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
clerkPkLifecycleService.startPk(pkId);
|
||||
} catch (Exception ex) {
|
||||
long retryAt = nowEpochSeconds + PkSchedulerConstants.START_RETRY_DELAY_SECONDS;
|
||||
stringRedisTemplate.opsForZSet().add(scheduleKey, pkId, retryAt);
|
||||
log.warn("PK 自动开始失败, pkId={}, retryAt={}", pkId, retryAt, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseLock(String lockKey, String lockValue) {
|
||||
String currentValue = stringRedisTemplate.opsForValue().get(lockKey);
|
||||
if (lockValue.equals(currentValue)) {
|
||||
stringRedisTemplate.delete(lockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.starry.admin.modules.pk.service;
|
||||
|
||||
import com.starry.admin.modules.pk.dto.PlayClerkPkForceStartRequest;
|
||||
|
||||
/**
|
||||
* 店员 PK 生命周期管理服务。
|
||||
*/
|
||||
public interface ClerkPkLifecycleService {
|
||||
|
||||
/**
|
||||
* 启动指定 PK(从待开始进入进行中)。
|
||||
*
|
||||
* @param pkId PK ID
|
||||
*/
|
||||
void startPk(String pkId);
|
||||
|
||||
/**
|
||||
* 完成并结算指定 PK。
|
||||
*
|
||||
* @param pkId PK ID
|
||||
*/
|
||||
void finishPk(String pkId);
|
||||
|
||||
/**
|
||||
* 扫描当前需要状态流转的 PK。
|
||||
*/
|
||||
void scanAndUpdate();
|
||||
|
||||
/**
|
||||
* 强制开始PK(用于测试,无需排期)。
|
||||
*
|
||||
* @param request 强制开始请求
|
||||
* @return PK ID
|
||||
*/
|
||||
String forceStart(PlayClerkPkForceStartRequest request);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.starry.admin.modules.pk.service;
|
||||
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
import com.starry.admin.modules.pk.dto.WxPkClerkHistoryPageDto;
|
||||
import com.starry.admin.modules.pk.dto.WxPkContributorDto;
|
||||
import com.starry.admin.modules.pk.dto.WxPkHistoryDto;
|
||||
import java.util.List;
|
||||
|
||||
public interface IPkDetailService {
|
||||
|
||||
List<WxPkContributorDto> getContributors(PlayClerkPkEntity pk);
|
||||
|
||||
List<WxPkHistoryDto> getHistory(PlayClerkPkEntity pk);
|
||||
|
||||
WxPkClerkHistoryPageDto getClerkHistory(String clerkId, int pageNum, int pageSize);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.starry.admin.modules.pk.service;
|
||||
|
||||
import com.starry.admin.modules.pk.dto.PkScoreBoardDto;
|
||||
|
||||
/**
|
||||
* PK 实时比分查询服务。
|
||||
*/
|
||||
public interface IPkScoreboardService {
|
||||
|
||||
/**
|
||||
* 查询指定 PK 的实时比分。
|
||||
*
|
||||
* @param pkId PK ID
|
||||
* @return 比分信息(不会为 null)
|
||||
*/
|
||||
PkScoreBoardDto getScoreboard(String pkId);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package com.starry.admin.modules.pk.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
|
||||
import com.starry.admin.modules.pk.dto.PlayClerkPkForceStartRequest;
|
||||
import com.starry.admin.modules.pk.enums.PkLifecycleErrorCode;
|
||||
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
|
||||
import com.starry.admin.modules.pk.scheduler.constants.PkSchedulerConstants;
|
||||
import com.starry.admin.modules.pk.service.ClerkPkLifecycleService;
|
||||
import com.starry.admin.modules.pk.setting.constants.PkSettingValidationConstants;
|
||||
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
|
||||
import com.starry.admin.modules.system.service.ISysTenantService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.admin.utils.TenantScope;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ClerkPkLifecycleServiceImpl implements ClerkPkLifecycleService {
|
||||
|
||||
@Resource
|
||||
private IPlayClerkPkService clerkPkService;
|
||||
|
||||
@Resource
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@Resource
|
||||
private ISysTenantService sysTenantService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void startPk(String pkId) {
|
||||
PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId);
|
||||
if (pk == null) {
|
||||
throw new CustomException("PK不存在");
|
||||
}
|
||||
if (!ClerkPkEnum.TO_BE_STARTED.name().equals(pk.getStatus())) {
|
||||
removeStartSchedule(pk);
|
||||
return;
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (pk.getPkBeginTime() != null
|
||||
&& LocalDateTime.ofInstant(pk.getPkBeginTime().toInstant(), ZoneId.systemDefault()).isAfter(now)) {
|
||||
throw new CustomException("PK开始时间尚未到达");
|
||||
}
|
||||
pk.setStatus(ClerkPkEnum.IN_PROGRESS.name());
|
||||
clerkPkService.updateById(pk);
|
||||
removeStartSchedule(pk);
|
||||
scheduleFinish(pk);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void finishPk(String pkId) {
|
||||
PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId);
|
||||
if (pk == null) {
|
||||
throw new CustomException("PK不存在");
|
||||
}
|
||||
if (ClerkPkEnum.FINISHED.name().equals(pk.getStatus())) {
|
||||
removeFinishSchedule(pk);
|
||||
return;
|
||||
}
|
||||
String scoreKey = PkRedisKeyConstants.scoreKey(pkId);
|
||||
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(scoreKey);
|
||||
|
||||
BigDecimal clerkAScore = parseDecimal(entries.get(PkRedisKeyConstants.FIELD_CLERK_A_SCORE));
|
||||
BigDecimal clerkBScore = parseDecimal(entries.get(PkRedisKeyConstants.FIELD_CLERK_B_SCORE));
|
||||
int clerkAOrderCount = parseInt(entries.get(PkRedisKeyConstants.FIELD_CLERK_A_ORDER_COUNT));
|
||||
int clerkBOrderCount = parseInt(entries.get(PkRedisKeyConstants.FIELD_CLERK_B_ORDER_COUNT));
|
||||
|
||||
pk.setClerkAScore(clerkAScore);
|
||||
pk.setClerkBScore(clerkBScore);
|
||||
pk.setClerkAOrderCount(clerkAOrderCount);
|
||||
pk.setClerkBOrderCount(clerkBOrderCount);
|
||||
pk.setStatus(ClerkPkEnum.FINISHED.name());
|
||||
pk.setSettled(1);
|
||||
if (clerkAScore.compareTo(clerkBScore) > 0) {
|
||||
pk.setWinnerClerkId(pk.getClerkA());
|
||||
} else if (clerkBScore.compareTo(clerkAScore) > 0) {
|
||||
pk.setWinnerClerkId(pk.getClerkB());
|
||||
} else {
|
||||
pk.setWinnerClerkId(null);
|
||||
}
|
||||
clerkPkService.updateById(pk);
|
||||
removeFinishSchedule(pk);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void scanAndUpdate() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Date nowDate = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
|
||||
|
||||
clerkPkService.list(Wrappers.<PlayClerkPkEntity>lambdaQuery()
|
||||
.eq(PlayClerkPkEntity::getStatus, ClerkPkEnum.TO_BE_STARTED.name())
|
||||
.le(PlayClerkPkEntity::getPkBeginTime, nowDate))
|
||||
.forEach(pk -> {
|
||||
try {
|
||||
startPk(pk.getId());
|
||||
} catch (Exception e) {
|
||||
log.warn("自动开始PK失败, pkId={}", pk.getId(), e);
|
||||
}
|
||||
});
|
||||
|
||||
clerkPkService.list(Wrappers.<PlayClerkPkEntity>lambdaQuery()
|
||||
.in(PlayClerkPkEntity::getStatus,
|
||||
ClerkPkEnum.IN_PROGRESS.name(), ClerkPkEnum.TO_BE_STARTED.name())
|
||||
.le(PlayClerkPkEntity::getPkEndTime, nowDate)
|
||||
.eq(PlayClerkPkEntity::getSettled, 0))
|
||||
.forEach(pk -> {
|
||||
try {
|
||||
finishPk(pk.getId());
|
||||
} catch (Exception e) {
|
||||
log.warn("自动结束PK失败, pkId={}", pk.getId(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String forceStart(PlayClerkPkForceStartRequest request) {
|
||||
validateForceStartRequest(request);
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (StrUtil.isBlank(tenantId)) {
|
||||
throw new CustomException(PkLifecycleErrorCode.TENANT_MISSING.getMessage());
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime endTime = now.plusMinutes(request.getDurationMinutes());
|
||||
Date beginDate = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
|
||||
Date endDate = Date.from(endTime.atZone(ZoneId.systemDefault()).toInstant());
|
||||
if (hasClerkConflict(request.getClerkAId(), request.getClerkBId(), beginDate, endDate)) {
|
||||
throw new CustomException(PkLifecycleErrorCode.CLERK_CONFLICT.getMessage());
|
||||
}
|
||||
PlayClerkPkEntity pk = new PlayClerkPkEntity();
|
||||
pk.setId(IdUtils.getUuid());
|
||||
pk.setTenantId(tenantId);
|
||||
pk.setClerkA(request.getClerkAId());
|
||||
pk.setClerkB(request.getClerkBId());
|
||||
pk.setPkBeginTime(beginDate);
|
||||
pk.setPkEndTime(endDate);
|
||||
pk.setStatus(ClerkPkEnum.IN_PROGRESS.name());
|
||||
pk.setSettled(0);
|
||||
pk.setCreatedBy(SecurityUtils.getUserId());
|
||||
clerkPkService.save(pk);
|
||||
scheduleFinish(pk);
|
||||
return pk.getId();
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = PkSchedulerConstants.FALLBACK_SCAN_INTERVAL_MILLIS)
|
||||
public void scheduledScan() {
|
||||
scanAndUpdateForAllTenants();
|
||||
}
|
||||
|
||||
private void scanAndUpdateForAllTenants() {
|
||||
List<SysTenantEntity> tenantEntities = sysTenantService.listAll();
|
||||
if (tenantEntities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (SysTenantEntity tenantEntity : tenantEntities) {
|
||||
String tenantId = tenantEntity.getTenantId();
|
||||
if (StrUtil.isBlank(tenantId)) {
|
||||
log.warn("PK扫描跳过空租户, errorCode={}", PkLifecycleErrorCode.TENANT_MISSING.name());
|
||||
continue;
|
||||
}
|
||||
try (TenantScope scope = TenantScope.use(tenantId)) {
|
||||
scanAndUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleFinish(PlayClerkPkEntity pk) {
|
||||
if (pk == null || pk.getPkEndTime() == null) {
|
||||
throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
String tenantId = pk.getTenantId();
|
||||
if (StrUtil.isBlank(tenantId)) {
|
||||
throw new CustomException(PkLifecycleErrorCode.TENANT_MISSING.getMessage());
|
||||
}
|
||||
long endEpochSeconds = pk.getPkEndTime().toInstant().getEpochSecond();
|
||||
String scheduleKey = PkRedisKeyConstants.finishScheduleKey(tenantId);
|
||||
stringRedisTemplate.opsForZSet().add(scheduleKey, pk.getId(), endEpochSeconds);
|
||||
}
|
||||
|
||||
private void removeStartSchedule(PlayClerkPkEntity pk) {
|
||||
if (pk == null || StrUtil.isBlank(pk.getTenantId())) {
|
||||
return;
|
||||
}
|
||||
String scheduleKey = PkRedisKeyConstants.startScheduleKey(pk.getTenantId());
|
||||
stringRedisTemplate.opsForZSet().remove(scheduleKey, pk.getId());
|
||||
}
|
||||
|
||||
private void removeFinishSchedule(PlayClerkPkEntity pk) {
|
||||
if (pk == null || StrUtil.isBlank(pk.getTenantId())) {
|
||||
return;
|
||||
}
|
||||
String tenantId = pk.getTenantId();
|
||||
String scheduleKey = PkRedisKeyConstants.finishScheduleKey(tenantId);
|
||||
stringRedisTemplate.opsForZSet().remove(scheduleKey, pk.getId());
|
||||
String retryKey = PkRedisKeyConstants.finishRetryKey(tenantId);
|
||||
stringRedisTemplate.opsForHash().delete(retryKey, pk.getId());
|
||||
String failedKey = PkRedisKeyConstants.finishFailedKey(tenantId);
|
||||
stringRedisTemplate.opsForZSet().remove(failedKey, pk.getId());
|
||||
}
|
||||
|
||||
private static BigDecimal parseDecimal(Object value) {
|
||||
if (value == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(value.toString());
|
||||
} catch (NumberFormatException ex) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseInt(Object value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(value.toString());
|
||||
} catch (NumberFormatException ex) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void validateForceStartRequest(PlayClerkPkForceStartRequest request) {
|
||||
if (request == null
|
||||
|| StrUtil.isBlank(request.getClerkAId())
|
||||
|| StrUtil.isBlank(request.getClerkBId())
|
||||
|| request.getDurationMinutes() == null) {
|
||||
throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
if (request.getClerkAId().equals(request.getClerkBId())) {
|
||||
throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
if (request.getDurationMinutes() < PkSettingValidationConstants.MIN_DURATION_MINUTES) {
|
||||
throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasClerkConflict(String clerkAId, String clerkBId, Date beginTime, Date endTime) {
|
||||
return clerkPkService.count(Wrappers.<PlayClerkPkEntity>lambdaQuery()
|
||||
.in(PlayClerkPkEntity::getStatus,
|
||||
ClerkPkEnum.TO_BE_STARTED.name(),
|
||||
ClerkPkEnum.IN_PROGRESS.name())
|
||||
.and(time -> time.le(PlayClerkPkEntity::getPkBeginTime, endTime)
|
||||
.ge(PlayClerkPkEntity::getPkEndTime, beginTime))
|
||||
.and(clerk -> clerk.eq(PlayClerkPkEntity::getClerkA, clerkAId)
|
||||
.or()
|
||||
.eq(PlayClerkPkEntity::getClerkB, clerkAId)
|
||||
.or()
|
||||
.eq(PlayClerkPkEntity::getClerkA, clerkBId)
|
||||
.or()
|
||||
.eq(PlayClerkPkEntity::getClerkB, clerkBId))) > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.starry.admin.modules.pk.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.clerk.mapper.PlayClerkPkMapper;
|
||||
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper;
|
||||
import com.starry.admin.modules.order.module.constant.OrderConstant;
|
||||
import com.starry.admin.modules.pk.constants.PkWxQueryConstants;
|
||||
import com.starry.admin.modules.pk.dto.WxPkClerkHistoryPageDto;
|
||||
import com.starry.admin.modules.pk.dto.WxPkClerkHistorySummaryDto;
|
||||
import com.starry.admin.modules.pk.dto.WxPkContributorDto;
|
||||
import com.starry.admin.modules.pk.dto.WxPkHistoryDto;
|
||||
import com.starry.admin.modules.pk.service.IPkDetailService;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class PkDetailServiceImpl implements IPkDetailService {
|
||||
|
||||
private static final ZoneId DEFAULT_ZONE = ZoneId.systemDefault();
|
||||
|
||||
@Resource
|
||||
private PlayOrderInfoMapper orderInfoMapper;
|
||||
|
||||
@Resource
|
||||
private PlayClerkPkMapper clerkPkMapper;
|
||||
|
||||
@Resource
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Override
|
||||
public List<WxPkContributorDto> getContributors(PlayClerkPkEntity pk) {
|
||||
validatePk(pk);
|
||||
LocalDateTime startTime = toLocalDateTime(pk.getPkBeginTime());
|
||||
LocalDateTime endTime = toLocalDateTime(pk.getPkEndTime());
|
||||
LocalDateTime now = LocalDateTime.now(DEFAULT_ZONE);
|
||||
if (endTime.isAfter(now)) {
|
||||
endTime = now;
|
||||
}
|
||||
if (endTime.isBefore(startTime)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<WxPkContributorDto> contributors = orderInfoMapper.selectPkContributors(
|
||||
pk.getTenantId(),
|
||||
pk.getClerkA(),
|
||||
pk.getClerkB(),
|
||||
startTime,
|
||||
endTime,
|
||||
OrderConstant.OrderStatus.COMPLETED.getCode(),
|
||||
PkWxQueryConstants.MIN_CONTRIBUTION_AMOUNT,
|
||||
PkWxQueryConstants.CONTRIBUTOR_LIMIT);
|
||||
if (contributors == null || contributors.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<WxPkContributorDto> normalized = new ArrayList<>();
|
||||
for (WxPkContributorDto contributor : contributors) {
|
||||
normalized.add(normalizeContributor(contributor));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<WxPkHistoryDto> getHistory(PlayClerkPkEntity pk) {
|
||||
validatePk(pk);
|
||||
List<PlayClerkPkEntity> history = clerkPkMapper.selectRecentFinishedBetweenClerks(
|
||||
pk.getTenantId(),
|
||||
pk.getClerkA(),
|
||||
pk.getClerkB(),
|
||||
ClerkPkEnum.FINISHED.name(),
|
||||
PkWxQueryConstants.HISTORY_LIMIT);
|
||||
if (history == null || history.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Map<String, String> clerkNames = loadClerkNames(pk.getClerkA(), pk.getClerkB());
|
||||
List<WxPkHistoryDto> items = new ArrayList<>();
|
||||
for (PlayClerkPkEntity item : history) {
|
||||
items.add(toHistoryDto(item, clerkNames));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WxPkClerkHistoryPageDto getClerkHistory(String clerkId, int pageNum, int pageSize) {
|
||||
if (StrUtil.isBlank(clerkId)) {
|
||||
throw new CustomException("店员ID不能为空");
|
||||
}
|
||||
int safePageNum = normalizePageParam(pageNum, PkWxQueryConstants.CLERK_HISTORY_PAGE_NUM);
|
||||
int safePageSize = normalizePageParam(pageSize, PkWxQueryConstants.CLERK_HISTORY_PAGE_SIZE);
|
||||
|
||||
com.baomidou.mybatisplus.extension.plugins.pagination.Page<PlayClerkPkEntity> page =
|
||||
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(safePageNum, safePageSize);
|
||||
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PlayClerkPkEntity> wrapper =
|
||||
com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery(PlayClerkPkEntity.class)
|
||||
.eq(PlayClerkPkEntity::getStatus, ClerkPkEnum.FINISHED.name())
|
||||
.and(w -> w.eq(PlayClerkPkEntity::getClerkA, clerkId)
|
||||
.or()
|
||||
.eq(PlayClerkPkEntity::getClerkB, clerkId))
|
||||
.orderByDesc(PlayClerkPkEntity::getPkBeginTime);
|
||||
com.baomidou.mybatisplus.extension.plugins.pagination.Page<PlayClerkPkEntity> result =
|
||||
clerkPkMapper.selectPage(page, wrapper);
|
||||
|
||||
List<WxPkHistoryDto> items = new ArrayList<>();
|
||||
if (result.getRecords() != null) {
|
||||
for (PlayClerkPkEntity item : result.getRecords()) {
|
||||
items.add(toClerkHistoryDto(item));
|
||||
}
|
||||
}
|
||||
|
||||
WxPkClerkHistoryPageDto pageDto = new WxPkClerkHistoryPageDto();
|
||||
pageDto.setItems(items);
|
||||
pageDto.setTotalCount(result.getTotal());
|
||||
pageDto.setPageNum(safePageNum);
|
||||
pageDto.setPageSize(safePageSize);
|
||||
pageDto.setSummary(buildSummary(clerkId, result.getTotal()));
|
||||
return pageDto;
|
||||
}
|
||||
|
||||
private void validatePk(PlayClerkPkEntity pk) {
|
||||
if (pk == null) {
|
||||
throw new CustomException("PK不存在");
|
||||
}
|
||||
if (StrUtil.isBlank(pk.getTenantId())) {
|
||||
throw new CustomException("租户信息缺失");
|
||||
}
|
||||
if (StrUtil.isBlank(pk.getClerkA()) || StrUtil.isBlank(pk.getClerkB())) {
|
||||
throw new CustomException("PK店员信息缺失");
|
||||
}
|
||||
if (pk.getPkBeginTime() == null || pk.getPkEndTime() == null) {
|
||||
throw new CustomException("PK时间信息缺失");
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDateTime toLocalDateTime(java.util.Date date) {
|
||||
if (date == null) {
|
||||
throw new CustomException("时间信息缺失");
|
||||
}
|
||||
return LocalDateTime.ofInstant(date.toInstant(), DEFAULT_ZONE);
|
||||
}
|
||||
|
||||
private WxPkContributorDto normalizeContributor(WxPkContributorDto source) {
|
||||
WxPkContributorDto dto = new WxPkContributorDto();
|
||||
if (source == null) {
|
||||
return dto;
|
||||
}
|
||||
if (StrUtil.isNotBlank(source.getUserId())) {
|
||||
dto.setUserId(source.getUserId());
|
||||
}
|
||||
if (StrUtil.isNotBlank(source.getNickname())) {
|
||||
dto.setNickname(source.getNickname());
|
||||
}
|
||||
if (source.getAmount() != null) {
|
||||
dto.setAmount(source.getAmount());
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private Map<String, String> loadClerkNames(String clerkAId, String clerkBId) {
|
||||
Map<String, String> names = new HashMap<>();
|
||||
PlayClerkUserInfoEntity clerkA = clerkUserInfoService.getById(clerkAId);
|
||||
PlayClerkUserInfoEntity clerkB = clerkUserInfoService.getById(clerkBId);
|
||||
names.put(clerkAId, clerkA == null ? "" : safeText(clerkA.getNickname()));
|
||||
names.put(clerkBId, clerkB == null ? "" : safeText(clerkB.getNickname()));
|
||||
return names;
|
||||
}
|
||||
|
||||
private WxPkHistoryDto toHistoryDto(PlayClerkPkEntity item, Map<String, String> clerkNames) {
|
||||
WxPkHistoryDto dto = new WxPkHistoryDto();
|
||||
if (item == null) {
|
||||
return dto;
|
||||
}
|
||||
dto.setClerkAName(safeText(clerkNames.get(item.getClerkA())));
|
||||
dto.setClerkBName(safeText(clerkNames.get(item.getClerkB())));
|
||||
dto.setClerkAScore(safeAmount(item.getClerkAScore()));
|
||||
dto.setClerkBScore(safeAmount(item.getClerkBScore()));
|
||||
dto.setId(safeText(item.getId()));
|
||||
dto.setClerkAId(safeText(item.getClerkA()));
|
||||
dto.setClerkBId(safeText(item.getClerkB()));
|
||||
dto.setWinnerClerkId(safeText(item.getWinnerClerkId()));
|
||||
if (item.getPkBeginTime() != null) {
|
||||
dto.setPkBeginTime(item.getPkBeginTime());
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private String safeText(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private BigDecimal safeAmount(BigDecimal value) {
|
||||
return value == null ? BigDecimal.ZERO : value;
|
||||
}
|
||||
|
||||
private int normalizePageParam(int value, int defaultValue) {
|
||||
if (value < PkWxQueryConstants.CLERK_HISTORY_MIN_PAGE) {
|
||||
return defaultValue;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private WxPkHistoryDto toClerkHistoryDto(PlayClerkPkEntity item) {
|
||||
WxPkHistoryDto dto = toHistoryDto(item, loadClerkNames(item.getClerkA(), item.getClerkB()));
|
||||
WxPkContributorDto topContributor = loadTopContributor(item);
|
||||
if (topContributor != null) {
|
||||
dto.setTopContributorName(safeText(topContributor.getNickname()));
|
||||
dto.setTopContributorAmount(safeAmount(topContributor.getAmount()));
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private WxPkContributorDto loadTopContributor(PlayClerkPkEntity pk) {
|
||||
if (pk == null || pk.getPkBeginTime() == null || pk.getPkEndTime() == null) {
|
||||
return null;
|
||||
}
|
||||
LocalDateTime startTime = toLocalDateTime(pk.getPkBeginTime());
|
||||
LocalDateTime endTime = toLocalDateTime(pk.getPkEndTime());
|
||||
List<WxPkContributorDto> contributors = orderInfoMapper.selectPkContributors(
|
||||
pk.getTenantId(),
|
||||
pk.getClerkA(),
|
||||
pk.getClerkB(),
|
||||
startTime,
|
||||
endTime,
|
||||
OrderConstant.OrderStatus.COMPLETED.getCode(),
|
||||
PkWxQueryConstants.MIN_CONTRIBUTION_AMOUNT,
|
||||
PkWxQueryConstants.TOP_CONTRIBUTOR_LIMIT);
|
||||
if (contributors == null || contributors.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return normalizeContributor(contributors.get(0));
|
||||
}
|
||||
|
||||
private WxPkClerkHistorySummaryDto buildSummary(String clerkId, long totalCount) {
|
||||
WxPkClerkHistorySummaryDto summary = new WxPkClerkHistorySummaryDto();
|
||||
summary.setTotalCount(totalCount);
|
||||
if (totalCount <= 0) {
|
||||
return summary;
|
||||
}
|
||||
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PlayClerkPkEntity> winWrapper =
|
||||
com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery(PlayClerkPkEntity.class)
|
||||
.eq(PlayClerkPkEntity::getStatus, ClerkPkEnum.FINISHED.name())
|
||||
.and(w -> w.eq(PlayClerkPkEntity::getClerkA, clerkId)
|
||||
.or()
|
||||
.eq(PlayClerkPkEntity::getClerkB, clerkId))
|
||||
.eq(PlayClerkPkEntity::getWinnerClerkId, clerkId);
|
||||
long winCount = clerkPkMapper.selectCount(winWrapper);
|
||||
summary.setWinCount(winCount);
|
||||
BigDecimal rate = BigDecimal.valueOf(winCount)
|
||||
.multiply(PkWxQueryConstants.WIN_RATE_MULTIPLIER)
|
||||
.divide(BigDecimal.valueOf(totalCount), PkWxQueryConstants.WIN_RATE_SCALE, RoundingMode.HALF_UP);
|
||||
summary.setWinRate(rate.stripTrailingZeros().toPlainString() + PkWxQueryConstants.PERCENT_SUFFIX);
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.starry.admin.modules.pk.service.impl;
|
||||
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
|
||||
import com.starry.admin.modules.pk.dto.PkScoreBoardDto;
|
||||
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
|
||||
import com.starry.admin.modules.pk.service.IPkScoreboardService;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Map;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class PkScoreboardServiceImpl implements IPkScoreboardService {
|
||||
|
||||
@Resource
|
||||
private IPlayClerkPkService clerkPkService;
|
||||
|
||||
@Resource
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@Override
|
||||
public PkScoreBoardDto getScoreboard(String pkId) {
|
||||
PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId);
|
||||
if (pk == null) {
|
||||
throw new CustomException("PK不存在");
|
||||
}
|
||||
PkScoreBoardDto dto = new PkScoreBoardDto();
|
||||
dto.setPkId(pk.getId());
|
||||
dto.setClerkAId(pk.getClerkA());
|
||||
dto.setClerkBId(pk.getClerkB());
|
||||
|
||||
String scoreKey = PkRedisKeyConstants.scoreKey(pkId);
|
||||
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(scoreKey);
|
||||
|
||||
BigDecimal clerkAScore = parseDecimal(entries.get(PkRedisKeyConstants.FIELD_CLERK_A_SCORE));
|
||||
BigDecimal clerkBScore = parseDecimal(entries.get(PkRedisKeyConstants.FIELD_CLERK_B_SCORE));
|
||||
int clerkAOrderCount = parseInt(entries.get(PkRedisKeyConstants.FIELD_CLERK_A_ORDER_COUNT));
|
||||
int clerkBOrderCount = parseInt(entries.get(PkRedisKeyConstants.FIELD_CLERK_B_ORDER_COUNT));
|
||||
|
||||
dto.setClerkAScore(clerkAScore);
|
||||
dto.setClerkBScore(clerkBScore);
|
||||
dto.setClerkAOrderCount(clerkAOrderCount);
|
||||
dto.setClerkBOrderCount(clerkBOrderCount);
|
||||
dto.setRemainingSeconds(calculateRemainingSeconds(pk));
|
||||
return dto;
|
||||
}
|
||||
|
||||
private static BigDecimal parseDecimal(Object value) {
|
||||
if (value == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(value.toString());
|
||||
} catch (NumberFormatException ex) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseInt(Object value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(value.toString());
|
||||
} catch (NumberFormatException ex) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static long calculateRemainingSeconds(PlayClerkPkEntity pk) {
|
||||
if (pk.getPkEndTime() == null) {
|
||||
return 0L;
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime endTime = LocalDateTime.ofInstant(pk.getPkEndTime().toInstant(), ZoneId.systemDefault());
|
||||
if (endTime.isBefore(now)) {
|
||||
return 0L;
|
||||
}
|
||||
return Duration.between(now, endTime).getSeconds();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.starry.admin.modules.pk.setting.constants;
|
||||
|
||||
public final class PkSettingApiConstants {
|
||||
|
||||
public static final String BASE_PATH = "/play/pk/settings";
|
||||
public static final String LIST_PATH = "/list";
|
||||
public static final String DETAIL_PATH = "/{id}";
|
||||
public static final String CREATE_PATH = "/create";
|
||||
public static final String BULK_CREATE_PATH = "/bulk-create";
|
||||
public static final String UPDATE_PATH = "/update/{id}";
|
||||
public static final String ENABLE_PATH = "/{id}/enable";
|
||||
public static final String DISABLE_PATH = "/{id}/disable";
|
||||
|
||||
private PkSettingApiConstants() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.starry.admin.modules.pk.setting.constants;
|
||||
|
||||
public final class PkSettingValidationConstants {
|
||||
|
||||
public static final int MIN_DURATION_MINUTES = 1;
|
||||
public static final int MAX_YEARS_AHEAD = 3;
|
||||
public static final int MIN_DAY_OF_MONTH = 1;
|
||||
public static final int MAX_DAY_OF_MONTH = 31;
|
||||
public static final int MIN_MONTH_OF_YEAR = 1;
|
||||
public static final int MAX_MONTH_OF_YEAR = 12;
|
||||
public static final int DEFAULT_PAGE_NUMBER = 1;
|
||||
public static final int DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
private PkSettingValidationConstants() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.starry.admin.modules.pk.setting.controller;
|
||||
|
||||
import com.starry.admin.modules.pk.setting.constants.PkSettingApiConstants;
|
||||
import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingBulkCreateRequest;
|
||||
import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingUpsertRequest;
|
||||
import com.starry.admin.modules.pk.setting.service.IPlayClerkPkSettingService;
|
||||
import com.starry.common.annotation.Log;
|
||||
import com.starry.common.enums.BusinessType;
|
||||
import com.starry.common.result.R;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiImplicitParam;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import io.swagger.annotations.ApiParam;
|
||||
import io.swagger.annotations.ApiResponse;
|
||||
import io.swagger.annotations.ApiResponses;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Api(tags = "店员PK排期设置", description = "店员PK排期设置相关接口")
|
||||
@RestController
|
||||
@RequestMapping(PkSettingApiConstants.BASE_PATH)
|
||||
public class PlayClerkPkSettingController {
|
||||
|
||||
@Resource
|
||||
private IPlayClerkPkSettingService pkSettingService;
|
||||
|
||||
/**
|
||||
* 查询PK排期设置列表
|
||||
*/
|
||||
@ApiOperation(value = "查询PK排期设置列表", notes = "分页查询PK排期设置")
|
||||
@ApiResponses({@ApiResponse(code = 200, message = "操作成功")})
|
||||
@GetMapping(PkSettingApiConstants.LIST_PATH)
|
||||
public R list() {
|
||||
return R.ok(pkSettingService.listSettings());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取PK排期设置详情
|
||||
*/
|
||||
@ApiOperation(value = "获取PK排期设置详情", notes = "根据ID获取PK排期设置详情")
|
||||
@ApiImplicitParam(name = "id", value = "设置ID", required = true, paramType = "path", dataType = "String")
|
||||
@ApiResponses({@ApiResponse(code = 200, message = "操作成功")})
|
||||
@GetMapping(PkSettingApiConstants.DETAIL_PATH)
|
||||
public R getInfo(@PathVariable("id") String id) {
|
||||
return R.ok(pkSettingService.getSetting(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增PK排期设置
|
||||
*/
|
||||
@ApiOperation(value = "新增PK排期设置", notes = "创建新的PK排期设置")
|
||||
@ApiResponses({@ApiResponse(code = 200, message = "操作成功"), @ApiResponse(code = 500, message = "添加失败")})
|
||||
@Log(title = "店员PK排期设置", businessType = BusinessType.INSERT)
|
||||
@PostMapping(PkSettingApiConstants.CREATE_PATH)
|
||||
public R create(
|
||||
@ApiParam(value = "PK排期设置", required = true) @RequestBody PlayClerkPkSettingUpsertRequest request) {
|
||||
String id = pkSettingService.createSetting(request);
|
||||
return R.ok(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量新增PK排期设置
|
||||
*/
|
||||
@ApiOperation(value = "批量新增PK排期设置", notes = "批量创建多个PK排期设置")
|
||||
@ApiResponses({@ApiResponse(code = 200, message = "操作成功"), @ApiResponse(code = 500, message = "添加失败")})
|
||||
@Log(title = "店员PK排期设置", businessType = BusinessType.INSERT)
|
||||
@PostMapping(PkSettingApiConstants.BULK_CREATE_PATH)
|
||||
public R bulkCreate(
|
||||
@ApiParam(value = "PK排期设置列表", required = true)
|
||||
@RequestBody PlayClerkPkSettingBulkCreateRequest request) {
|
||||
return R.ok(pkSettingService.createSettings(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改PK排期设置
|
||||
*/
|
||||
@ApiOperation(value = "修改PK排期设置", notes = "根据ID修改PK排期设置")
|
||||
@ApiImplicitParam(name = "id", value = "设置ID", required = true, paramType = "path", dataType = "String")
|
||||
@ApiResponses({@ApiResponse(code = 200, message = "操作成功"), @ApiResponse(code = 500, message = "修改失败")})
|
||||
@Log(title = "店员PK排期设置", businessType = BusinessType.UPDATE)
|
||||
@PostMapping(PkSettingApiConstants.UPDATE_PATH)
|
||||
public R update(@PathVariable("id") String id,
|
||||
@ApiParam(value = "PK排期设置", required = true) @RequestBody PlayClerkPkSettingUpsertRequest request) {
|
||||
pkSettingService.updateSetting(id, request);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用PK排期设置
|
||||
*/
|
||||
@ApiOperation(value = "启用PK排期设置", notes = "启用指定PK排期设置")
|
||||
@ApiImplicitParam(name = "id", value = "设置ID", required = true, paramType = "path", dataType = "String")
|
||||
@ApiResponses({@ApiResponse(code = 200, message = "操作成功")})
|
||||
@Log(title = "店员PK排期设置", businessType = BusinessType.UPDATE)
|
||||
@PostMapping(PkSettingApiConstants.ENABLE_PATH)
|
||||
public R enable(@PathVariable("id") String id) {
|
||||
pkSettingService.enableSetting(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用PK排期设置
|
||||
*/
|
||||
@ApiOperation(value = "停用PK排期设置", notes = "停用指定PK排期设置")
|
||||
@ApiImplicitParam(name = "id", value = "设置ID", required = true, paramType = "path", dataType = "String")
|
||||
@ApiResponses({@ApiResponse(code = 200, message = "操作成功")})
|
||||
@Log(title = "店员PK排期设置", businessType = BusinessType.UPDATE)
|
||||
@PostMapping(PkSettingApiConstants.DISABLE_PATH)
|
||||
public R disable(@PathVariable("id") String id) {
|
||||
pkSettingService.disableSetting(id);
|
||||
return R.ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.starry.admin.modules.pk.setting.dto;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
@ApiModel(value = "PlayClerkPkSettingBulkCreateRequest", description = "店员PK排期设置批量创建请求")
|
||||
@Data
|
||||
public class PlayClerkPkSettingBulkCreateRequest {
|
||||
|
||||
@ApiModelProperty(value = "批量排期设置列表", required = true)
|
||||
private List<PlayClerkPkSettingUpsertRequest> settings;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.starry.admin.modules.pk.setting.dto;
|
||||
|
||||
import com.starry.admin.modules.pk.setting.enums.PkRecurrenceType;
|
||||
import com.starry.admin.modules.pk.setting.enums.PkScheduleDayOfWeek;
|
||||
import com.starry.admin.modules.pk.setting.enums.PkSettingStatus;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import lombok.Data;
|
||||
|
||||
@ApiModel(value = "PlayClerkPkSettingUpsertRequest", description = "店员PK排期设置创建/更新请求")
|
||||
@Data
|
||||
public class PlayClerkPkSettingUpsertRequest {
|
||||
|
||||
@ApiModelProperty(value = "设置名称", required = true)
|
||||
private String name;
|
||||
|
||||
@ApiModelProperty(value = "循环类型", required = true)
|
||||
private PkRecurrenceType recurrenceType;
|
||||
|
||||
@ApiModelProperty(value = "星期几")
|
||||
private PkScheduleDayOfWeek dayOfWeek;
|
||||
|
||||
@ApiModelProperty(value = "每月第N天")
|
||||
private Integer dayOfMonth;
|
||||
|
||||
@ApiModelProperty(value = "每年月份")
|
||||
private Integer monthOfYear;
|
||||
|
||||
@ApiModelProperty(value = "每日开始时间", required = true)
|
||||
private LocalTime startTimeOfDay;
|
||||
|
||||
@ApiModelProperty(value = "持续分钟数", required = true)
|
||||
private Integer durationMinutes;
|
||||
|
||||
@ApiModelProperty(value = "生效开始日期", required = true)
|
||||
private LocalDate effectiveStartDate;
|
||||
|
||||
@ApiModelProperty(value = "生效结束日期")
|
||||
private LocalDate effectiveEndDate;
|
||||
|
||||
@ApiModelProperty(value = "时区", required = true)
|
||||
private String timezone;
|
||||
|
||||
@ApiModelProperty(value = "店员A ID", required = true)
|
||||
private String clerkAId;
|
||||
|
||||
@ApiModelProperty(value = "店员B ID", required = true)
|
||||
private String clerkBId;
|
||||
|
||||
@ApiModelProperty(value = "状态", required = true)
|
||||
private PkSettingStatus status;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.starry.admin.modules.pk.setting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@ApiModel(value = "PlayClerkPkSettingEntity", description = "店员PK排期设置")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("play_clerk_pk_settings")
|
||||
public class PlayClerkPkSettingEntity extends BaseEntity<PlayClerkPkSettingEntity> {
|
||||
|
||||
@ApiModelProperty(value = "UUID", required = true)
|
||||
private String id;
|
||||
|
||||
@ApiModelProperty(value = "租户ID", required = true)
|
||||
private String tenantId;
|
||||
|
||||
@ApiModelProperty(value = "设置名称", required = true)
|
||||
private String name;
|
||||
|
||||
@ApiModelProperty(value = "循环类型", required = true)
|
||||
private String recurrenceType;
|
||||
|
||||
@ApiModelProperty(value = "星期几")
|
||||
private String dayOfWeek;
|
||||
|
||||
@ApiModelProperty(value = "每月第N天")
|
||||
private Integer dayOfMonth;
|
||||
|
||||
@ApiModelProperty(value = "每年月份")
|
||||
private Integer monthOfYear;
|
||||
|
||||
@ApiModelProperty(value = "每日开始时间", required = true)
|
||||
private LocalTime startTimeOfDay;
|
||||
|
||||
@ApiModelProperty(value = "持续分钟数", required = true)
|
||||
private Integer durationMinutes;
|
||||
|
||||
@ApiModelProperty(value = "生效开始日期", required = true)
|
||||
private LocalDate effectiveStartDate;
|
||||
|
||||
@ApiModelProperty(value = "生效结束日期")
|
||||
private LocalDate effectiveEndDate;
|
||||
|
||||
@ApiModelProperty(value = "时区", required = true)
|
||||
private String timezone;
|
||||
|
||||
@ApiModelProperty(value = "店员A ID", required = true)
|
||||
private String clerkAId;
|
||||
|
||||
@ApiModelProperty(value = "店员B ID", required = true)
|
||||
private String clerkBId;
|
||||
|
||||
@ApiModelProperty(value = "状态", required = true)
|
||||
private String status;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.starry.admin.modules.pk.setting.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@AllArgsConstructor
|
||||
public enum PkRecurrenceType {
|
||||
ONCE("ONCE", "单次"),
|
||||
DAILY("DAILY", "每日"),
|
||||
WEEKLY("WEEKLY", "每周"),
|
||||
MONTHLY("MONTHLY", "每月"),
|
||||
YEARLY("YEARLY", "每年");
|
||||
|
||||
@Getter
|
||||
private final String value;
|
||||
@Getter
|
||||
private final String desc;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.starry.admin.modules.pk.setting.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@AllArgsConstructor
|
||||
public enum PkScheduleDayOfWeek {
|
||||
MONDAY("MONDAY", "周一"),
|
||||
TUESDAY("TUESDAY", "周二"),
|
||||
WEDNESDAY("WEDNESDAY", "周三"),
|
||||
THURSDAY("THURSDAY", "周四"),
|
||||
FRIDAY("FRIDAY", "周五"),
|
||||
SATURDAY("SATURDAY", "周六"),
|
||||
SUNDAY("SUNDAY", "周日");
|
||||
|
||||
@Getter
|
||||
private final String value;
|
||||
@Getter
|
||||
private final String desc;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.starry.admin.modules.pk.setting.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@AllArgsConstructor
|
||||
public enum PkSettingErrorCode {
|
||||
NOT_IMPLEMENTED("PK_SETTING_NOT_IMPLEMENTED", "PK排期设置功能尚未实现"),
|
||||
TENANT_MISSING("PK_SETTING_TENANT_MISSING", "租户信息不能为空"),
|
||||
SETTING_NOT_FOUND("PK_SETTING_NOT_FOUND", "PK排期设置不存在"),
|
||||
REQUEST_INVALID("PK_SETTING_REQUEST_INVALID", "PK排期设置参数非法"),
|
||||
RECURRENCE_INVALID("PK_SETTING_RECURRENCE_INVALID", "PK排期循环规则非法"),
|
||||
TIME_RANGE_INVALID("PK_SETTING_TIME_RANGE_INVALID", "PK排期时间范围非法"),
|
||||
CLERK_CONFLICT("PK_SETTING_CLERK_CONFLICT", "店员排期冲突");
|
||||
|
||||
@Getter
|
||||
private final String code;
|
||||
@Getter
|
||||
private final String message;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.starry.admin.modules.pk.setting.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@AllArgsConstructor
|
||||
public enum PkSettingStatus {
|
||||
ENABLED("ENABLED", "启用"),
|
||||
DISABLED("DISABLED", "停用");
|
||||
|
||||
@Getter
|
||||
private final String value;
|
||||
@Getter
|
||||
private final String desc;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.starry.admin.modules.pk.setting.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.pk.setting.entity.PlayClerkPkSettingEntity;
|
||||
|
||||
public interface PlayClerkPkSettingMapper extends BaseMapper<PlayClerkPkSettingEntity> {
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.starry.admin.modules.pk.setting.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingBulkCreateRequest;
|
||||
import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingUpsertRequest;
|
||||
import com.starry.admin.modules.pk.setting.entity.PlayClerkPkSettingEntity;
|
||||
import java.util.List;
|
||||
|
||||
public interface IPlayClerkPkSettingService extends IService<PlayClerkPkSettingEntity> {
|
||||
|
||||
String createSetting(PlayClerkPkSettingUpsertRequest request);
|
||||
|
||||
List<String> createSettings(PlayClerkPkSettingBulkCreateRequest request);
|
||||
|
||||
void updateSetting(String id, PlayClerkPkSettingUpsertRequest request);
|
||||
|
||||
void enableSetting(String id);
|
||||
|
||||
void disableSetting(String id);
|
||||
|
||||
PlayClerkPkSettingEntity getSetting(String id);
|
||||
|
||||
IPage<PlayClerkPkSettingEntity> listSettings();
|
||||
|
||||
int generateInstances(String id);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package com.starry.admin.modules.pk.setting.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
|
||||
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
|
||||
import com.starry.admin.modules.pk.setting.constants.PkSettingValidationConstants;
|
||||
import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingBulkCreateRequest;
|
||||
import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingUpsertRequest;
|
||||
import com.starry.admin.modules.pk.setting.entity.PlayClerkPkSettingEntity;
|
||||
import com.starry.admin.modules.pk.setting.enums.PkRecurrenceType;
|
||||
import com.starry.admin.modules.pk.setting.enums.PkScheduleDayOfWeek;
|
||||
import com.starry.admin.modules.pk.setting.enums.PkSettingErrorCode;
|
||||
import com.starry.admin.modules.pk.setting.enums.PkSettingStatus;
|
||||
import com.starry.admin.modules.pk.setting.mapper.PlayClerkPkSettingMapper;
|
||||
import com.starry.admin.modules.pk.setting.service.IPlayClerkPkSettingService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class PlayClerkPkSettingServiceImpl
|
||||
extends ServiceImpl<PlayClerkPkSettingMapper, PlayClerkPkSettingEntity>
|
||||
implements IPlayClerkPkSettingService {
|
||||
|
||||
private final IPlayClerkPkService clerkPkService;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private static final String PAIR_KEY_SEPARATOR = "::";
|
||||
|
||||
public PlayClerkPkSettingServiceImpl(IPlayClerkPkService clerkPkService,
|
||||
StringRedisTemplate stringRedisTemplate) {
|
||||
this.clerkPkService = clerkPkService;
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String createSetting(PlayClerkPkSettingUpsertRequest request) {
|
||||
PlayClerkPkSettingEntity entity = buildEntity(request, null);
|
||||
entity.setId(IdUtils.getUuid());
|
||||
if (!save(entity)) {
|
||||
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
if (PkSettingStatus.ENABLED.getValue().equals(entity.getStatus())) {
|
||||
generateInstances(entity.getId());
|
||||
}
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public List<String> createSettings(PlayClerkPkSettingBulkCreateRequest request) {
|
||||
List<PlayClerkPkSettingUpsertRequest> settings = validateBulkRequest(request);
|
||||
List<String> createdIds = new ArrayList<>();
|
||||
for (PlayClerkPkSettingUpsertRequest settingRequest : settings) {
|
||||
PlayClerkPkSettingEntity entity = buildEntity(settingRequest, null);
|
||||
entity.setId(IdUtils.getUuid());
|
||||
if (!save(entity)) {
|
||||
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
if (PkSettingStatus.ENABLED.getValue().equals(entity.getStatus())) {
|
||||
generateInstances(entity.getId());
|
||||
}
|
||||
createdIds.add(entity.getId());
|
||||
}
|
||||
return createdIds;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateSetting(String id, PlayClerkPkSettingUpsertRequest request) {
|
||||
PlayClerkPkSettingEntity existing = getSetting(id);
|
||||
PlayClerkPkSettingEntity entity = buildEntity(request, existing);
|
||||
entity.setId(existing.getId());
|
||||
entity.setTenantId(existing.getTenantId());
|
||||
if (!updateById(entity)) {
|
||||
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void enableSetting(String id) {
|
||||
PlayClerkPkSettingEntity setting = getSetting(id);
|
||||
setting.setStatus(PkSettingStatus.ENABLED.getValue());
|
||||
updateById(setting);
|
||||
generateInstances(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void disableSetting(String id) {
|
||||
PlayClerkPkSettingEntity setting = getSetting(id);
|
||||
setting.setStatus(PkSettingStatus.DISABLED.getValue());
|
||||
updateById(setting);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlayClerkPkSettingEntity getSetting(String id) {
|
||||
if (StrUtil.isBlank(id)) {
|
||||
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
PlayClerkPkSettingEntity setting = getById(id);
|
||||
if (setting == null) {
|
||||
throw new CustomException(PkSettingErrorCode.SETTING_NOT_FOUND.getMessage());
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<PlayClerkPkSettingEntity> listSettings() {
|
||||
Page<PlayClerkPkSettingEntity> page = new Page<>(
|
||||
PkSettingValidationConstants.DEFAULT_PAGE_NUMBER,
|
||||
PkSettingValidationConstants.DEFAULT_PAGE_SIZE);
|
||||
return baseMapper.selectPage(page, new LambdaQueryWrapper<>());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int generateInstances(String id) {
|
||||
PlayClerkPkSettingEntity setting = getSetting(id);
|
||||
if (!PkSettingStatus.ENABLED.getValue().equals(setting.getStatus())) {
|
||||
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
List<LocalDate> dates = generateScheduleDates(setting);
|
||||
ZoneId zoneId = ZoneId.of(setting.getTimezone());
|
||||
int createdCount = 0;
|
||||
for (LocalDate date : dates) {
|
||||
LocalDateTime startTime = LocalDateTime.of(date, setting.getStartTimeOfDay());
|
||||
LocalDateTime endTime = startTime.plusMinutes(setting.getDurationMinutes());
|
||||
Date beginTime = Date.from(startTime.atZone(zoneId).toInstant());
|
||||
Date finishTime = Date.from(endTime.atZone(zoneId).toInstant());
|
||||
|
||||
if (existsForSettingAt(setting.getId(), beginTime)) {
|
||||
continue;
|
||||
}
|
||||
if (hasClerkConflict(setting, beginTime, finishTime)) {
|
||||
throw new CustomException(PkSettingErrorCode.CLERK_CONFLICT.getMessage());
|
||||
}
|
||||
PlayClerkPkEntity pk = new PlayClerkPkEntity();
|
||||
pk.setId(IdUtils.getUuid());
|
||||
pk.setTenantId(setting.getTenantId());
|
||||
pk.setClerkA(setting.getClerkAId());
|
||||
pk.setClerkB(setting.getClerkBId());
|
||||
pk.setPkBeginTime(beginTime);
|
||||
pk.setPkEndTime(finishTime);
|
||||
pk.setStatus(ClerkPkEnum.TO_BE_STARTED.name());
|
||||
pk.setSettled(0);
|
||||
pk.setSettingId(setting.getId());
|
||||
clerkPkService.save(pk);
|
||||
scheduleStart(pk);
|
||||
createdCount++;
|
||||
}
|
||||
return createdCount;
|
||||
}
|
||||
|
||||
private PlayClerkPkSettingEntity buildEntity(PlayClerkPkSettingUpsertRequest request,
|
||||
PlayClerkPkSettingEntity existing) {
|
||||
validateRequest(request);
|
||||
PlayClerkPkSettingEntity entity = Optional.ofNullable(existing).orElseGet(PlayClerkPkSettingEntity::new);
|
||||
entity.setName(request.getName());
|
||||
entity.setRecurrenceType(request.getRecurrenceType().getValue());
|
||||
entity.setDayOfWeek(Optional.ofNullable(request.getDayOfWeek())
|
||||
.map(PkScheduleDayOfWeek::getValue)
|
||||
.orElse(null));
|
||||
entity.setDayOfMonth(request.getDayOfMonth());
|
||||
entity.setMonthOfYear(request.getMonthOfYear());
|
||||
entity.setStartTimeOfDay(request.getStartTimeOfDay());
|
||||
entity.setDurationMinutes(request.getDurationMinutes());
|
||||
entity.setEffectiveStartDate(request.getEffectiveStartDate());
|
||||
entity.setEffectiveEndDate(request.getEffectiveEndDate());
|
||||
entity.setTimezone(request.getTimezone());
|
||||
entity.setClerkAId(request.getClerkAId());
|
||||
entity.setClerkBId(request.getClerkBId());
|
||||
entity.setStatus(request.getStatus().getValue());
|
||||
if (existing == null) {
|
||||
entity.setTenantId(resolveTenantId());
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
private void validateRequest(PlayClerkPkSettingUpsertRequest request) {
|
||||
if (request == null) {
|
||||
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
if (StrUtil.isBlank(request.getName())
|
||||
|| request.getRecurrenceType() == null
|
||||
|| request.getStartTimeOfDay() == null
|
||||
|| request.getDurationMinutes() == null
|
||||
|| request.getEffectiveStartDate() == null
|
||||
|| request.getTimezone() == null
|
||||
|| StrUtil.isBlank(request.getClerkAId())
|
||||
|| StrUtil.isBlank(request.getClerkBId())
|
||||
|| request.getStatus() == null) {
|
||||
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
if (request.getDurationMinutes() < PkSettingValidationConstants.MIN_DURATION_MINUTES) {
|
||||
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
if (request.getClerkAId().equals(request.getClerkBId())) {
|
||||
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
ensureZoneValid(request.getTimezone());
|
||||
validateRecurrenceFields(request);
|
||||
validateEffectiveRange(request.getEffectiveStartDate(), request.getEffectiveEndDate());
|
||||
}
|
||||
|
||||
private void scheduleStart(PlayClerkPkEntity pk) {
|
||||
if (pk == null || pk.getPkBeginTime() == null || pk.getId() == null) {
|
||||
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
if (StrUtil.isBlank(pk.getTenantId())) {
|
||||
throw new CustomException(PkSettingErrorCode.TENANT_MISSING.getMessage());
|
||||
}
|
||||
String scheduleKey = PkRedisKeyConstants.startScheduleKey(pk.getTenantId());
|
||||
long startEpochSeconds = pk.getPkBeginTime().toInstant().getEpochSecond();
|
||||
stringRedisTemplate.opsForZSet().add(scheduleKey, pk.getId(), startEpochSeconds);
|
||||
}
|
||||
|
||||
private List<PlayClerkPkSettingUpsertRequest> validateBulkRequest(PlayClerkPkSettingBulkCreateRequest request) {
|
||||
if (request == null || request.getSettings() == null || request.getSettings().isEmpty()) {
|
||||
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
Set<String> pairKeys = new HashSet<>();
|
||||
for (PlayClerkPkSettingUpsertRequest setting : request.getSettings()) {
|
||||
validateRequest(setting);
|
||||
String pairKey = buildPairKey(setting.getClerkAId(), setting.getClerkBId());
|
||||
if (!pairKeys.add(pairKey)) {
|
||||
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
}
|
||||
return request.getSettings();
|
||||
}
|
||||
|
||||
private String buildPairKey(String clerkAId, String clerkBId) {
|
||||
if (clerkAId.compareTo(clerkBId) <= 0) {
|
||||
return clerkAId + PAIR_KEY_SEPARATOR + clerkBId;
|
||||
}
|
||||
return clerkBId + PAIR_KEY_SEPARATOR + clerkAId;
|
||||
}
|
||||
|
||||
private void ensureZoneValid(String timezone) {
|
||||
try {
|
||||
ZoneId.of(timezone);
|
||||
} catch (Exception ex) {
|
||||
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRecurrenceFields(PlayClerkPkSettingUpsertRequest request) {
|
||||
PkRecurrenceType type = request.getRecurrenceType();
|
||||
if (type == PkRecurrenceType.WEEKLY && request.getDayOfWeek() == null) {
|
||||
throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage());
|
||||
}
|
||||
if (type == PkRecurrenceType.MONTHLY && request.getDayOfMonth() == null) {
|
||||
throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage());
|
||||
}
|
||||
if (type == PkRecurrenceType.YEARLY
|
||||
&& (request.getDayOfMonth() == null || request.getMonthOfYear() == null)) {
|
||||
throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage());
|
||||
}
|
||||
if (request.getDayOfMonth() != null) {
|
||||
int dayOfMonth = request.getDayOfMonth();
|
||||
if (dayOfMonth < PkSettingValidationConstants.MIN_DAY_OF_MONTH
|
||||
|| dayOfMonth > PkSettingValidationConstants.MAX_DAY_OF_MONTH) {
|
||||
throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage());
|
||||
}
|
||||
}
|
||||
if (request.getMonthOfYear() != null) {
|
||||
int monthOfYear = request.getMonthOfYear();
|
||||
if (monthOfYear < PkSettingValidationConstants.MIN_MONTH_OF_YEAR
|
||||
|| monthOfYear > PkSettingValidationConstants.MAX_MONTH_OF_YEAR) {
|
||||
throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateEffectiveRange(LocalDate start, LocalDate end) {
|
||||
if (end != null && end.isBefore(start)) {
|
||||
throw new CustomException(PkSettingErrorCode.TIME_RANGE_INVALID.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private List<LocalDate> generateScheduleDates(PlayClerkPkSettingEntity setting) {
|
||||
LocalDate start = setting.getEffectiveStartDate();
|
||||
LocalDate maxEnd = start.plusYears(PkSettingValidationConstants.MAX_YEARS_AHEAD);
|
||||
LocalDate end = Optional.ofNullable(setting.getEffectiveEndDate()).orElse(maxEnd);
|
||||
if (end.isAfter(maxEnd)) {
|
||||
end = maxEnd;
|
||||
}
|
||||
PkRecurrenceType type = PkRecurrenceType.valueOf(setting.getRecurrenceType());
|
||||
List<LocalDate> dates = new ArrayList<>();
|
||||
switch (type) {
|
||||
case ONCE:
|
||||
if (!start.isAfter(end)) {
|
||||
dates.add(start);
|
||||
}
|
||||
break;
|
||||
case DAILY:
|
||||
for (LocalDate cursor = start; !cursor.isAfter(end); cursor = cursor.plusDays(1)) {
|
||||
dates.add(cursor);
|
||||
}
|
||||
break;
|
||||
case WEEKLY:
|
||||
dates.addAll(generateWeeklyDates(setting, start, end));
|
||||
break;
|
||||
case MONTHLY:
|
||||
dates.addAll(generateMonthlyDates(setting, start, end));
|
||||
break;
|
||||
case YEARLY:
|
||||
dates.addAll(generateYearlyDates(setting, start, end));
|
||||
break;
|
||||
default:
|
||||
throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage());
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
private List<LocalDate> generateWeeklyDates(PlayClerkPkSettingEntity setting, LocalDate start, LocalDate end) {
|
||||
List<LocalDate> dates = new ArrayList<>();
|
||||
DayOfWeek target = DayOfWeek.valueOf(setting.getDayOfWeek());
|
||||
LocalDate cursor = start;
|
||||
while (cursor.getDayOfWeek() != target) {
|
||||
cursor = cursor.plusDays(1);
|
||||
}
|
||||
for (LocalDate date = cursor; !date.isAfter(end); date = date.plusWeeks(1)) {
|
||||
dates.add(date);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
private List<LocalDate> generateMonthlyDates(PlayClerkPkSettingEntity setting, LocalDate start, LocalDate end) {
|
||||
List<LocalDate> dates = new ArrayList<>();
|
||||
int dayOfMonth = setting.getDayOfMonth();
|
||||
YearMonth startMonth = YearMonth.from(start);
|
||||
YearMonth endMonth = YearMonth.from(end);
|
||||
for (YearMonth cursor = startMonth; !cursor.isAfter(endMonth); cursor = cursor.plusMonths(1)) {
|
||||
if (dayOfMonth > cursor.lengthOfMonth()) {
|
||||
continue;
|
||||
}
|
||||
LocalDate date = cursor.atDay(dayOfMonth);
|
||||
if (date.isBefore(start) || date.isAfter(end)) {
|
||||
continue;
|
||||
}
|
||||
dates.add(date);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
private List<LocalDate> generateYearlyDates(PlayClerkPkSettingEntity setting, LocalDate start, LocalDate end) {
|
||||
List<LocalDate> dates = new ArrayList<>();
|
||||
int dayOfMonth = setting.getDayOfMonth();
|
||||
int monthOfYear = setting.getMonthOfYear();
|
||||
for (int year = start.getYear(); year <= end.getYear(); year++) {
|
||||
YearMonth yearMonth = YearMonth.of(year, monthOfYear);
|
||||
if (dayOfMonth > yearMonth.lengthOfMonth()) {
|
||||
continue;
|
||||
}
|
||||
LocalDate date = LocalDate.of(year, monthOfYear, dayOfMonth);
|
||||
if (date.isBefore(start) || date.isAfter(end)) {
|
||||
continue;
|
||||
}
|
||||
dates.add(date);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
private boolean existsForSettingAt(String settingId, Date beginTime) {
|
||||
LambdaQueryWrapper<PlayClerkPkEntity> wrapper = Wrappers.lambdaQuery(PlayClerkPkEntity.class)
|
||||
.eq(PlayClerkPkEntity::getSettingId, settingId)
|
||||
.eq(PlayClerkPkEntity::getPkBeginTime, beginTime);
|
||||
return clerkPkService.count(wrapper) > 0;
|
||||
}
|
||||
|
||||
private boolean hasClerkConflict(PlayClerkPkSettingEntity setting, Date beginTime, Date endTime) {
|
||||
LambdaQueryWrapper<PlayClerkPkEntity> wrapper = Wrappers.lambdaQuery(PlayClerkPkEntity.class)
|
||||
.in(PlayClerkPkEntity::getStatus,
|
||||
ClerkPkEnum.TO_BE_STARTED.name(),
|
||||
ClerkPkEnum.IN_PROGRESS.name())
|
||||
.and(time -> time.le(PlayClerkPkEntity::getPkBeginTime, endTime)
|
||||
.ge(PlayClerkPkEntity::getPkEndTime, beginTime))
|
||||
.and(clerk -> clerk.eq(PlayClerkPkEntity::getClerkA, setting.getClerkAId())
|
||||
.or()
|
||||
.eq(PlayClerkPkEntity::getClerkB, setting.getClerkAId())
|
||||
.or()
|
||||
.eq(PlayClerkPkEntity::getClerkA, setting.getClerkBId())
|
||||
.or()
|
||||
.eq(PlayClerkPkEntity::getClerkB, setting.getClerkBId()));
|
||||
return clerkPkService.count(wrapper) > 0;
|
||||
}
|
||||
|
||||
private String resolveTenantId() {
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (StrUtil.isBlank(tenantId)) {
|
||||
throw new CustomException(PkSettingErrorCode.TENANT_MISSING.getMessage());
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.starry.admin.modules.shop.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||
import com.github.yulichang.base.MPJBaseMapper;
|
||||
import com.starry.admin.modules.shop.module.entity.PlayCommodityInfoEntity;
|
||||
import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
/**
|
||||
* 服务项目Mapper接口
|
||||
@@ -34,4 +37,12 @@ public interface PlayCommodityInfoMapper extends MPJBaseMapper<PlayCommodityInfo
|
||||
@Select("select t.id as commodityId,t3.price as commodityPrice,t.item_name as serviceDuration,t1.item_name as commodityName from play_commodity_info t left join play_commodity_info t1 on t.p_id = t1.id left join play_commodity_and_level_info t3 ON t3.commodity_id = t.id where t3.price is not null and t.id = #{id} and t3.level_id = #{levelId} limit 1")
|
||||
PlayCommodityInfoVo queryCommodityInfo(String id, String levelId);
|
||||
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("SELECT id, tenant_id, deleted FROM play_commodity_info WHERE id = #{id} LIMIT 1")
|
||||
PlayCommodityInfoEntity selectByIdIncludingDeleted(@Param("id") String id);
|
||||
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Update("UPDATE play_commodity_info SET deleted = 0, tenant_id = #{tenantId}, enable_stace = '1' WHERE id = #{id}")
|
||||
int restoreCommodity(@Param("id") String id, @Param("tenantId") String tenantId);
|
||||
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ public class ClerkPerformanceOverviewQueryVo extends PlayClerkPerformanceInfoQue
|
||||
@ApiModelProperty(value = "是否包含排行列表数据")
|
||||
private Boolean includeRankings = Boolean.TRUE;
|
||||
|
||||
@ApiModelProperty(value = "是否包含收益调整(ADJUSTMENT)", allowableValues = "true,false")
|
||||
private Boolean includeAdjustments = Boolean.FALSE;
|
||||
|
||||
@Override
|
||||
public void setEndOrderTime(List<String> endOrderTime) {
|
||||
super.setEndOrderTime(endOrderTime);
|
||||
|
||||
@@ -142,6 +142,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
public ClerkPerformanceOverviewResponseVo queryOverview(ClerkPerformanceOverviewQueryVo vo) {
|
||||
DateRange range = resolveDateRange(vo.getEndOrderTime());
|
||||
List<PlayClerkUserInfoEntity> clerks = loadAccessibleClerks(vo);
|
||||
boolean includeAdjustments = Boolean.TRUE.equals(vo.getIncludeAdjustments());
|
||||
ClerkPerformanceOverviewResponseVo responseVo = new ClerkPerformanceOverviewResponseVo();
|
||||
if (CollectionUtil.isEmpty(clerks)) {
|
||||
responseVo.setSummary(new ClerkPerformanceOverviewSummaryVo());
|
||||
@@ -158,7 +159,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
for (PlayClerkUserInfoEntity clerk : clerks) {
|
||||
List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
|
||||
range.startTime, range.endTime);
|
||||
snapshots.add(buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime));
|
||||
snapshots.add(buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime, includeAdjustments));
|
||||
}
|
||||
int total = snapshots.size();
|
||||
ClerkPerformanceOverviewSummaryVo summary = aggregateSummary(snapshots);
|
||||
@@ -194,7 +195,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
|
||||
range.startTime, range.endTime);
|
||||
ClerkPerformanceSnapshotVo snapshot =
|
||||
buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime);
|
||||
buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime, false);
|
||||
ClerkPerformanceDetailResponseVo responseVo = new ClerkPerformanceDetailResponseVo();
|
||||
responseVo.setProfile(buildProfile(clerk, levelNameMap, groupNameMap));
|
||||
responseVo.setSnapshot(snapshot);
|
||||
@@ -424,22 +425,42 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
|
||||
private BigDecimal calculateEarningsAmount(String clerkId, List<PlayOrderInfoEntity> orders, String startTime,
|
||||
String endTime) {
|
||||
if (StrUtil.isBlank(clerkId) || CollectionUtil.isEmpty(orders)) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
List<String> orderIds = orders.stream()
|
||||
.filter(this::isCompletedOrder)
|
||||
.map(PlayOrderInfoEntity::getId)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.collect(Collectors.toList());
|
||||
if (CollectionUtil.isEmpty(orderIds)) {
|
||||
return calculateEarningsAmount(SecurityUtils.getTenantId(), clerkId, orders, startTime, endTime, false);
|
||||
}
|
||||
|
||||
private BigDecimal calculateEarningsAmount(
|
||||
String tenantId,
|
||||
String clerkId,
|
||||
List<PlayOrderInfoEntity> orders,
|
||||
String startTime,
|
||||
String endTime,
|
||||
boolean includeAdjustments) {
|
||||
if (StrUtil.isBlank(clerkId)) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
String normalizedStart = DateRangeUtils.normalizeStartOptional(startTime);
|
||||
String normalizedEnd = DateRangeUtils.normalizeEndOptional(endTime);
|
||||
BigDecimal sum = earningsLineMapper.sumAmountByClerkAndOrderIds(clerkId, orderIds, normalizedStart,
|
||||
normalizedEnd);
|
||||
return defaultZero(sum);
|
||||
|
||||
BigDecimal orderSum = BigDecimal.ZERO;
|
||||
if (CollectionUtil.isNotEmpty(orders)) {
|
||||
List<String> orderIds = orders.stream()
|
||||
.filter(this::isCompletedOrder)
|
||||
.map(PlayOrderInfoEntity::getId)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.collect(Collectors.toList());
|
||||
if (CollectionUtil.isNotEmpty(orderIds)) {
|
||||
BigDecimal sum = earningsLineMapper.sumAmountByClerkAndOrderIds(clerkId, orderIds, normalizedStart,
|
||||
normalizedEnd);
|
||||
orderSum = defaultZero(sum);
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeAdjustments) {
|
||||
return orderSum;
|
||||
}
|
||||
|
||||
BigDecimal adjustmentSum = earningsLineMapper.sumAdjustmentsByClerk(tenantId, clerkId, normalizedStart, normalizedEnd);
|
||||
return orderSum.add(defaultZero(adjustmentSum));
|
||||
}
|
||||
|
||||
private OrderConstant.OrderRelationType normalizeRelationType(PlayOrderInfoEntity order) {
|
||||
@@ -460,7 +481,8 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
}
|
||||
|
||||
private ClerkPerformanceSnapshotVo buildSnapshot(PlayClerkUserInfoEntity clerk, List<PlayOrderInfoEntity> orders,
|
||||
Map<String, String> levelNameMap, Map<String, String> groupNameMap, String startTime, String endTime) {
|
||||
Map<String, String> levelNameMap, Map<String, String> groupNameMap, String startTime, String endTime,
|
||||
boolean includeAdjustments) {
|
||||
ClerkPerformanceSnapshotVo snapshot = new ClerkPerformanceSnapshotVo();
|
||||
snapshot.setClerkId(clerk.getId());
|
||||
snapshot.setClerkNickname(clerk.getNickname());
|
||||
@@ -513,7 +535,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
}
|
||||
int userCount = userIds.size();
|
||||
int continuedUserCount = continuedUserIds.size();
|
||||
BigDecimal estimatedRevenue = calculateEarningsAmount(clerk.getId(), orders, startTime, endTime);
|
||||
BigDecimal estimatedRevenue = calculateEarningsAmount(SecurityUtils.getTenantId(), clerk.getId(), orders, startTime, endTime, includeAdjustments);
|
||||
snapshot.setGmv(gmv);
|
||||
snapshot.setFirstOrderAmount(firstAmount);
|
||||
snapshot.setContinuedOrderAmount(continuedAmount);
|
||||
|
||||
@@ -17,6 +17,7 @@ import io.swagger.annotations.ApiImplicitParam;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import io.swagger.annotations.ApiResponse;
|
||||
import io.swagger.annotations.ApiResponses;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -40,6 +41,9 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping("/wx/commodity/")
|
||||
public class WxClerkCommodityController {
|
||||
|
||||
private static final String ROOT_PARENT_ID = "00";
|
||||
private static final String CLERK_COMMODITY_ENABLED = "1";
|
||||
|
||||
@Resource
|
||||
private IPlayCommodityInfoService playCommodityInfoService;
|
||||
|
||||
@@ -63,6 +67,12 @@ public class WxClerkCommodityController {
|
||||
if (levelId == null || levelId.isEmpty()) {
|
||||
return R.ok(tree);
|
||||
}
|
||||
if (levelInfoEntities == null) {
|
||||
throw new CustomException("商品等级信息缺失");
|
||||
}
|
||||
if (tree == null) {
|
||||
throw new CustomException("商品树缺失");
|
||||
}
|
||||
tree = formatPlayCommodityReturnVoTree(tree, levelInfoEntities, levelId);
|
||||
tree = formatPlayCommodityReturnVoTree(tree, null);
|
||||
return R.ok(tree);
|
||||
@@ -84,11 +94,23 @@ public class WxClerkCommodityController {
|
||||
throw new CustomException("请求参数异常,id不能为空");
|
||||
}
|
||||
PlayClerkUserInfoEntity clerkUserInfo = clerkUserInfoService.selectById(clerkId);
|
||||
if (clerkUserInfo == null) {
|
||||
throw new CustomException("店员不存在");
|
||||
}
|
||||
if (clerkUserInfo.getLevelId() == null || clerkUserInfo.getLevelId().isEmpty()) {
|
||||
throw new CustomException("店员等级信息缺失");
|
||||
}
|
||||
Map<String, List<PlayClerkCommodityEntity>> clerkCommodityEntities = playClerkCommodityService
|
||||
.selectCommodityTypeByUser(clerkId, "1").stream()
|
||||
.selectCommodityTypeByUser(clerkId, CLERK_COMMODITY_ENABLED).stream()
|
||||
.collect(Collectors.groupingBy(PlayClerkCommodityEntity::getCommodityId));
|
||||
List<PlayCommodityAndLevelInfoEntity> levelInfoEntities = iPlayCommodityAndLevelInfoService.selectAll();
|
||||
List<PlayCommodityReturnVo> tree = playCommodityInfoService.selectTree();
|
||||
if (levelInfoEntities == null) {
|
||||
throw new CustomException("商品等级信息缺失");
|
||||
}
|
||||
if (tree == null) {
|
||||
throw new CustomException("商品树缺失");
|
||||
}
|
||||
tree = formatPlayCommodityReturnVoTree(tree, levelInfoEntities, clerkUserInfo.getLevelId());
|
||||
tree = formatPlayCommodityReturnVoTree(tree, clerkCommodityEntities);
|
||||
return R.ok(tree);
|
||||
@@ -108,10 +130,16 @@ public class WxClerkCommodityController {
|
||||
String levelId = ThreadLocalRequestDetail.getClerkUserInfo().getLevelId();
|
||||
List<PlayCommodityAndLevelInfoEntity> levelInfoEntities = iPlayCommodityAndLevelInfoService.selectAll();
|
||||
Map<String, List<PlayClerkCommodityEntity>> clerkCommodityEntities = playClerkCommodityService
|
||||
.selectCommodityTypeByUser(ThreadLocalRequestDetail.getClerkUserInfo().getId(), "1").stream()
|
||||
.selectCommodityTypeByUser(ThreadLocalRequestDetail.getClerkUserInfo().getId(), CLERK_COMMODITY_ENABLED).stream()
|
||||
.collect(Collectors.groupingBy(PlayClerkCommodityEntity::getCommodityId));
|
||||
|
||||
List<PlayCommodityReturnVo> tree = playCommodityInfoService.selectTree();
|
||||
if (levelInfoEntities == null) {
|
||||
throw new CustomException("商品等级信息缺失");
|
||||
}
|
||||
if (tree == null) {
|
||||
throw new CustomException("商品树缺失");
|
||||
}
|
||||
tree = formatPlayCommodityReturnVoTree(tree, levelInfoEntities, levelId);
|
||||
tree = formatPlayCommodityReturnVoTree(tree, clerkCommodityEntities);
|
||||
return R.ok(tree);
|
||||
@@ -119,9 +147,21 @@ public class WxClerkCommodityController {
|
||||
|
||||
public List<PlayCommodityReturnVo> formatPlayCommodityReturnVoTree(List<PlayCommodityReturnVo> tree,
|
||||
List<PlayCommodityAndLevelInfoEntity> levelInfoEntities, String levelId) {
|
||||
if (tree == null) {
|
||||
throw new CustomException("商品树缺失");
|
||||
}
|
||||
if (levelInfoEntities == null) {
|
||||
throw new CustomException("商品等级信息缺失");
|
||||
}
|
||||
if (levelId == null || levelId.isEmpty()) {
|
||||
throw new CustomException("等级信息缺失");
|
||||
}
|
||||
Iterator<PlayCommodityReturnVo> it = tree.iterator();
|
||||
while (it.hasNext()) {
|
||||
PlayCommodityReturnVo item = it.next();
|
||||
if (item.getChild() == null) {
|
||||
item.setChild(new ArrayList<>());
|
||||
}
|
||||
// 查找当前服务项目对应的价格
|
||||
for (PlayCommodityAndLevelInfoEntity levelInfoEntity : levelInfoEntities) {
|
||||
if (item.getId().equals(levelInfoEntity.getCommodityId())
|
||||
@@ -130,7 +170,7 @@ public class WxClerkCommodityController {
|
||||
}
|
||||
}
|
||||
// 如果未设置价格,删除元素
|
||||
if (!"00".equals(item.getPId()) && item.getPrice() == null) {
|
||||
if (!ROOT_PARENT_ID.equals(item.getPId()) && item.getPrice() == null) {
|
||||
it.remove();
|
||||
}
|
||||
formatPlayCommodityReturnVoTree(item.getChild(), levelInfoEntities, levelId);
|
||||
@@ -140,12 +180,18 @@ public class WxClerkCommodityController {
|
||||
|
||||
public List<PlayCommodityReturnVo> formatPlayCommodityReturnVoTree(List<PlayCommodityReturnVo> tree,
|
||||
Map<String, List<PlayClerkCommodityEntity>> clerkCommodityEntities) {
|
||||
if (tree == null) {
|
||||
throw new CustomException("商品树缺失");
|
||||
}
|
||||
Iterator<PlayCommodityReturnVo> it = tree.iterator();
|
||||
while (it.hasNext()) {
|
||||
PlayCommodityReturnVo item = it.next();
|
||||
if ("00".equals(item.getPId()) && item.getChild().isEmpty()) {
|
||||
if (item.getChild() == null) {
|
||||
item.setChild(new ArrayList<>());
|
||||
}
|
||||
if (ROOT_PARENT_ID.equals(item.getPId()) && item.getChild().isEmpty()) {
|
||||
it.remove();
|
||||
} else if (clerkCommodityEntities != null && "00".equals(item.getPId())
|
||||
} else if (clerkCommodityEntities != null && ROOT_PARENT_ID.equals(item.getPId())
|
||||
&& !clerkCommodityEntities.containsKey(item.getId())) {
|
||||
it.remove();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
package com.starry.admin.modules.weichat.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.pk.constants.PkWxQueryConstants;
|
||||
import com.starry.admin.modules.pk.dto.PkScoreBoardDto;
|
||||
import com.starry.admin.modules.pk.dto.WxPkClerkHistoryPageDto;
|
||||
import com.starry.admin.modules.pk.dto.WxPkDetailDto;
|
||||
import com.starry.admin.modules.pk.dto.WxPkLiveDto;
|
||||
import com.starry.admin.modules.pk.dto.WxPkUpcomingDto;
|
||||
import com.starry.admin.modules.pk.enums.PkWxState;
|
||||
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
|
||||
import com.starry.admin.modules.pk.service.IPkDetailService;
|
||||
import com.starry.admin.modules.pk.service.IPkScoreboardService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.result.R;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Api(tags = "微信PK接口", description = "微信端PK展示相关接口")
|
||||
@RestController
|
||||
@RequestMapping("/wx/pk")
|
||||
public class WxPkController {
|
||||
|
||||
private final IPlayClerkPkService clerkPkService;
|
||||
private final IPlayClerkUserInfoService clerkUserInfoService;
|
||||
private final IPkScoreboardService pkScoreboardService;
|
||||
private final IPkDetailService pkDetailService;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
public WxPkController(IPlayClerkPkService clerkPkService,
|
||||
IPlayClerkUserInfoService clerkUserInfoService,
|
||||
IPkScoreboardService pkScoreboardService,
|
||||
IPkDetailService pkDetailService,
|
||||
StringRedisTemplate stringRedisTemplate) {
|
||||
this.clerkPkService = clerkPkService;
|
||||
this.clerkUserInfoService = clerkUserInfoService;
|
||||
this.pkScoreboardService = pkScoreboardService;
|
||||
this.pkDetailService = pkDetailService;
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
}
|
||||
|
||||
@ApiOperation(value = "店员PK实时状态", notes = "查询指定店员是否正在PK")
|
||||
@GetMapping("/clerk/live")
|
||||
public R getClerkLive(@RequestParam("clerkId") String clerkId) {
|
||||
if (StrUtil.isBlank(clerkId)) {
|
||||
throw new CustomException("店员ID不能为空");
|
||||
}
|
||||
Optional<PlayClerkPkEntity> pkOptional =
|
||||
clerkPkService.findActivePkForClerk(clerkId, LocalDateTime.now());
|
||||
if (!pkOptional.isPresent()) {
|
||||
return R.ok(WxPkLiveDto.inactive());
|
||||
}
|
||||
PlayClerkPkEntity pk = pkOptional.get();
|
||||
if (!ClerkPkEnum.IN_PROGRESS.name().equals(pk.getStatus())) {
|
||||
return R.ok(WxPkLiveDto.inactive());
|
||||
}
|
||||
PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(pk.getId());
|
||||
return R.ok(buildLiveDto(pk, scoreboard));
|
||||
}
|
||||
|
||||
@ApiOperation(value = "即将开战PK", notes = "返回即将开始的PK信息")
|
||||
@GetMapping("/upcoming")
|
||||
public R getUpcoming() {
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (StrUtil.isBlank(tenantId)) {
|
||||
throw new CustomException("租户信息缺失");
|
||||
}
|
||||
String key = PkRedisKeyConstants.upcomingKey(tenantId);
|
||||
long nowEpochSeconds = Instant.now().getEpochSecond();
|
||||
Set<String> pkIds = stringRedisTemplate.opsForZSet()
|
||||
.rangeByScore(key, nowEpochSeconds, Double.POSITIVE_INFINITY, 0, 1);
|
||||
if (pkIds == null || pkIds.isEmpty()) {
|
||||
return R.ok(WxPkUpcomingDto.inactive());
|
||||
}
|
||||
String pkId = pkIds.iterator().next();
|
||||
PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId);
|
||||
if (pk == null) {
|
||||
return R.ok(WxPkUpcomingDto.inactive());
|
||||
}
|
||||
return R.ok(buildUpcomingDto(pk));
|
||||
}
|
||||
|
||||
@ApiOperation(value = "PK详情", notes = "查询PK详情用于详情页")
|
||||
@GetMapping("/detail")
|
||||
public R getDetail(@RequestParam("id") String id) {
|
||||
if (StrUtil.isBlank(id)) {
|
||||
throw new CustomException("PK ID不能为空");
|
||||
}
|
||||
PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(id);
|
||||
if (pk == null) {
|
||||
return R.ok(WxPkDetailDto.inactive());
|
||||
}
|
||||
PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(pk.getId());
|
||||
return R.ok(buildDetailDto(pk, scoreboard));
|
||||
}
|
||||
|
||||
@ApiOperation(value = "店员PK历史", notes = "查询指定店员PK历史")
|
||||
@GetMapping("/clerk/history")
|
||||
public R getClerkHistory(@RequestParam("clerkId") String clerkId,
|
||||
@RequestParam(value = "pageNum", required = false) Integer pageNum,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||
if (StrUtil.isBlank(clerkId)) {
|
||||
throw new CustomException("店员ID不能为空");
|
||||
}
|
||||
int safePageNum = pageNum == null ? PkWxQueryConstants.CLERK_HISTORY_PAGE_NUM : pageNum;
|
||||
int safePageSize = pageSize == null ? PkWxQueryConstants.CLERK_HISTORY_PAGE_SIZE : pageSize;
|
||||
WxPkClerkHistoryPageDto data = pkDetailService.getClerkHistory(clerkId, safePageNum, safePageSize);
|
||||
return R.ok(data);
|
||||
}
|
||||
|
||||
@ApiOperation(value = "店员PK日程", notes = "查询指定店员未来PK安排")
|
||||
@GetMapping("/clerk/schedule")
|
||||
public R getClerkSchedule(@RequestParam("clerkId") String clerkId,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
if (StrUtil.isBlank(clerkId)) {
|
||||
throw new CustomException("店员ID不能为空");
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (StrUtil.isBlank(tenantId)) {
|
||||
throw new CustomException("租户信息缺失");
|
||||
}
|
||||
int safeLimit = normalizeLimit(limit);
|
||||
List<PlayClerkPkEntity> items = clerkPkService.selectUpcomingForClerk(
|
||||
tenantId,
|
||||
clerkId,
|
||||
new Date(),
|
||||
safeLimit);
|
||||
if (items == null || items.isEmpty()) {
|
||||
return R.ok(new ArrayList<>());
|
||||
}
|
||||
List<WxPkUpcomingDto> result = new ArrayList<>();
|
||||
for (PlayClerkPkEntity pk : items) {
|
||||
result.add(buildUpcomingDto(pk));
|
||||
}
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
private WxPkLiveDto buildLiveDto(PlayClerkPkEntity pk, PkScoreBoardDto scoreboard) {
|
||||
WxPkLiveDto dto = new WxPkLiveDto();
|
||||
fillBase(dto, pk);
|
||||
dto.setState(PkWxState.ACTIVE.getValue());
|
||||
dto.setClerkAScore(scoreboard.getClerkAScore());
|
||||
dto.setClerkBScore(scoreboard.getClerkBScore());
|
||||
dto.setClerkAOrderCount(scoreboard.getClerkAOrderCount());
|
||||
dto.setClerkBOrderCount(scoreboard.getClerkBOrderCount());
|
||||
dto.setRemainingSeconds(scoreboard.getRemainingSeconds());
|
||||
dto.setServerEpochSeconds(Instant.now().getEpochSecond());
|
||||
if (pk.getPkEndTime() != null) {
|
||||
dto.setPkEndEpochSeconds(pk.getPkEndTime().toInstant().getEpochSecond());
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private WxPkUpcomingDto buildUpcomingDto(PlayClerkPkEntity pk) {
|
||||
WxPkUpcomingDto dto = new WxPkUpcomingDto();
|
||||
fillBase(dto, pk);
|
||||
dto.setState(PkWxState.UPCOMING.getValue());
|
||||
dto.setPkBeginTime(pk.getPkBeginTime());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private WxPkDetailDto buildDetailDto(PlayClerkPkEntity pk, PkScoreBoardDto scoreboard) {
|
||||
WxPkDetailDto dto = new WxPkDetailDto();
|
||||
fillBase(dto, pk);
|
||||
dto.setState(resolveState(pk.getStatus()));
|
||||
dto.setClerkAScore(scoreboard.getClerkAScore());
|
||||
dto.setClerkBScore(scoreboard.getClerkBScore());
|
||||
dto.setClerkAOrderCount(scoreboard.getClerkAOrderCount());
|
||||
dto.setClerkBOrderCount(scoreboard.getClerkBOrderCount());
|
||||
dto.setRemainingSeconds(scoreboard.getRemainingSeconds());
|
||||
dto.setPkBeginTime(pk.getPkBeginTime());
|
||||
dto.setPkEndTime(pk.getPkEndTime());
|
||||
dto.setContributors(pkDetailService.getContributors(pk));
|
||||
dto.setHistory(pkDetailService.getHistory(pk));
|
||||
return dto;
|
||||
}
|
||||
|
||||
private int normalizeLimit(Integer limit) {
|
||||
if (limit == null) {
|
||||
return PkWxQueryConstants.CLERK_SCHEDULE_DEFAULT_LIMIT;
|
||||
}
|
||||
if (limit < PkWxQueryConstants.CLERK_SCHEDULE_MIN_LIMIT) {
|
||||
return PkWxQueryConstants.CLERK_SCHEDULE_MIN_LIMIT;
|
||||
}
|
||||
if (limit > PkWxQueryConstants.CLERK_SCHEDULE_MAX_LIMIT) {
|
||||
return PkWxQueryConstants.CLERK_SCHEDULE_MAX_LIMIT;
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
private String resolveState(String status) {
|
||||
if (ClerkPkEnum.IN_PROGRESS.name().equals(status)) {
|
||||
return PkWxState.ACTIVE.getValue();
|
||||
}
|
||||
if (ClerkPkEnum.TO_BE_STARTED.name().equals(status)) {
|
||||
return PkWxState.UPCOMING.getValue();
|
||||
}
|
||||
return PkWxState.INACTIVE.getValue();
|
||||
}
|
||||
|
||||
private void fillBase(WxPkLiveDto dto, PlayClerkPkEntity pk) {
|
||||
dto.setId(pk.getId());
|
||||
dto.setClerkAId(pk.getClerkA());
|
||||
dto.setClerkBId(pk.getClerkB());
|
||||
fillClerkInfo(dto);
|
||||
}
|
||||
|
||||
private void fillBase(WxPkUpcomingDto dto, PlayClerkPkEntity pk) {
|
||||
dto.setId(pk.getId());
|
||||
dto.setClerkAId(pk.getClerkA());
|
||||
dto.setClerkBId(pk.getClerkB());
|
||||
fillClerkInfo(dto);
|
||||
}
|
||||
|
||||
private void fillBase(WxPkDetailDto dto, PlayClerkPkEntity pk) {
|
||||
dto.setId(pk.getId());
|
||||
dto.setClerkAId(pk.getClerkA());
|
||||
dto.setClerkBId(pk.getClerkB());
|
||||
fillClerkInfo(dto);
|
||||
}
|
||||
|
||||
private void fillClerkInfo(WxPkLiveDto dto) {
|
||||
fillClerkInfo(dto.getClerkAId(), dto.getClerkBId(), dto);
|
||||
}
|
||||
|
||||
private void fillClerkInfo(WxPkUpcomingDto dto) {
|
||||
fillClerkInfo(dto.getClerkAId(), dto.getClerkBId(), dto);
|
||||
}
|
||||
|
||||
private void fillClerkInfo(WxPkDetailDto dto) {
|
||||
fillClerkInfo(dto.getClerkAId(), dto.getClerkBId(), dto);
|
||||
}
|
||||
|
||||
private void fillClerkInfo(String clerkAId, String clerkBId, Object target) {
|
||||
PlayClerkUserInfoEntity clerkA = clerkUserInfoService.getById(clerkAId);
|
||||
PlayClerkUserInfoEntity clerkB = clerkUserInfoService.getById(clerkBId);
|
||||
if (target instanceof WxPkLiveDto) {
|
||||
WxPkLiveDto dto = (WxPkLiveDto) target;
|
||||
dto.setClerkAName(clerkA == null ? "" : clerkA.getNickname());
|
||||
dto.setClerkBName(clerkB == null ? "" : clerkB.getNickname());
|
||||
dto.setClerkAAvatar(clerkA == null ? "" : clerkA.getAvatar());
|
||||
dto.setClerkBAvatar(clerkB == null ? "" : clerkB.getAvatar());
|
||||
return;
|
||||
}
|
||||
if (target instanceof WxPkUpcomingDto) {
|
||||
WxPkUpcomingDto dto = (WxPkUpcomingDto) target;
|
||||
dto.setClerkAName(clerkA == null ? "" : clerkA.getNickname());
|
||||
dto.setClerkBName(clerkB == null ? "" : clerkB.getNickname());
|
||||
dto.setClerkAAvatar(clerkA == null ? "" : clerkA.getAvatar());
|
||||
dto.setClerkBAvatar(clerkB == null ? "" : clerkB.getAvatar());
|
||||
return;
|
||||
}
|
||||
if (target instanceof WxPkDetailDto) {
|
||||
WxPkDetailDto dto = (WxPkDetailDto) target;
|
||||
dto.setClerkAName(clerkA == null ? "" : clerkA.getNickname());
|
||||
dto.setClerkBName(clerkB == null ? "" : clerkB.getNickname());
|
||||
dto.setClerkAAvatar(clerkA == null ? "" : clerkA.getAvatar());
|
||||
dto.setClerkBAvatar(clerkB == null ? "" : clerkB.getAvatar());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,9 @@ public class WxCustomMpService {
|
||||
@Resource
|
||||
private WxMpService wxMpService;
|
||||
|
||||
@Value("${wechat.subscribe-check-enabled:true}")
|
||||
private boolean subscribeCheckEnabled;
|
||||
|
||||
@Resource
|
||||
private SysTenantServiceImpl tenantService;
|
||||
@Resource
|
||||
@@ -480,6 +483,9 @@ public class WxCustomMpService {
|
||||
if (StrUtil.isBlankIfStr(openId)) {
|
||||
throw new ServiceException("openId不能为空");
|
||||
}
|
||||
if (!subscribeCheckEnabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
WxMpUser wxMpUser = proxyWxMpService(tenantId).getUserService().userInfo(openId);
|
||||
if (!wxMpUser.getSubscribe()) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.starry.admin.modules.withdraw.controller;
|
||||
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.result.TypedR;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import javax.annotation.Resource;
|
||||
import lombok.Data;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/earnings/adjustments")
|
||||
public class AdminEarningsAdjustmentController {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
|
||||
@Resource
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
@Data
|
||||
public static class CreateAdjustmentRequest {
|
||||
private String clerkId;
|
||||
private BigDecimal amount;
|
||||
private EarningsAdjustmentReasonType reasonType;
|
||||
private String reasonDescription;
|
||||
private LocalDateTime effectiveTime;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AdjustmentStatusResponse {
|
||||
private String adjustmentId;
|
||||
private String idempotencyKey;
|
||||
private String status;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:adjustment:create') and @earningsAuth.canManageClerk(#body.clerkId)")
|
||||
public ResponseEntity<TypedR<AdjustmentStatusResponse>> create(
|
||||
@RequestHeader(value = IDEMPOTENCY_HEADER, required = false) String idempotencyKey,
|
||||
@RequestBody CreateAdjustmentRequest body) {
|
||||
|
||||
if (!StringUtils.hasText(idempotencyKey)) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "Idempotency-Key required"));
|
||||
}
|
||||
if (body == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "body required"));
|
||||
}
|
||||
if (!StringUtils.hasText(body.getClerkId())) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "clerkId required"));
|
||||
}
|
||||
if (body.getAmount() == null || body.getAmount().compareTo(BigDecimal.ZERO) == 0) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "amount must be non-zero"));
|
||||
}
|
||||
if (body.getReasonType() == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonType required"));
|
||||
}
|
||||
if (!StringUtils.hasText(body.getReasonDescription()) || !StringUtils.hasText(body.getReasonDescription().trim())) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonDescription required"));
|
||||
}
|
||||
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "tenant missing"));
|
||||
}
|
||||
|
||||
EarningsLineAdjustmentEntity adjustment;
|
||||
try {
|
||||
adjustment = adjustmentService.createOrGetProcessing(
|
||||
tenantId,
|
||||
body.getClerkId(),
|
||||
body.getAmount(),
|
||||
body.getReasonType(),
|
||||
body.getReasonDescription(),
|
||||
idempotencyKey,
|
||||
body.getEffectiveTime());
|
||||
} catch (IllegalStateException conflict) {
|
||||
return ResponseEntity.status(409).body(TypedR.error(409, conflict.getMessage()));
|
||||
} catch (CustomException bad) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, bad.getMessage()));
|
||||
}
|
||||
|
||||
adjustmentService.triggerApplyAsync(adjustment.getId());
|
||||
|
||||
AdjustmentStatusResponse responseBody = new AdjustmentStatusResponse();
|
||||
responseBody.setAdjustmentId(adjustment.getId());
|
||||
responseBody.setIdempotencyKey(idempotencyKey);
|
||||
responseBody.setStatus(adjustment.getStatus() == null ? "PROCESSING" : adjustment.getStatus().name());
|
||||
|
||||
TypedR<AdjustmentStatusResponse> result = TypedR.ok(responseBody);
|
||||
result.setCode(202);
|
||||
result.setMessage("请求处理中");
|
||||
return ResponseEntity.accepted()
|
||||
.header("Location", "/admin/earnings/adjustments/idempotency/" + idempotencyKey)
|
||||
.body(result);
|
||||
}
|
||||
|
||||
@GetMapping("/idempotency/{key}")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:adjustment:read') and @earningsAuth.canReadAdjustmentByIdempotencyKey(#key)")
|
||||
public ResponseEntity<TypedR<AdjustmentStatusResponse>> getByIdempotencyKey(@PathVariable("key") String key) {
|
||||
if (!StringUtils.hasText(key)) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
EarningsLineAdjustmentEntity adjustment = adjustmentService.getByIdempotencyKey(tenantId, key);
|
||||
if (adjustment == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
AdjustmentStatusResponse responseBody = new AdjustmentStatusResponse();
|
||||
responseBody.setAdjustmentId(adjustment.getId());
|
||||
responseBody.setIdempotencyKey(key);
|
||||
responseBody.setStatus(adjustment.getStatus() == null ? "PROCESSING" : adjustment.getStatus().name());
|
||||
return ResponseEntity.ok(TypedR.ok(responseBody));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package com.starry.admin.modules.withdraw.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionItemMapper;
|
||||
import com.starry.admin.modules.withdraw.security.EarningsAuthorizationService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsDeductionBatchService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.result.TypedR;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.annotation.Resource;
|
||||
import lombok.Data;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/earnings/deductions")
|
||||
public class AdminEarningsDeductionBatchController {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final DateTimeFormatter SIMPLE_DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Resource
|
||||
private IEarningsDeductionBatchService batchService;
|
||||
|
||||
@Resource
|
||||
private EarningsDeductionItemMapper deductionItemMapper;
|
||||
|
||||
@Resource
|
||||
private EarningsAuthorizationService earningsAuth;
|
||||
|
||||
@Data
|
||||
public static class DeductionRequest {
|
||||
private List<String> clerkIds;
|
||||
private String beginTime;
|
||||
private String endTime;
|
||||
private EarningsDeductionRuleType ruleType;
|
||||
private BigDecimal amount;
|
||||
private BigDecimal percentage;
|
||||
private EarningsDeductionOperationType operation;
|
||||
private String reasonDescription;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PreviewItem {
|
||||
private String clerkId;
|
||||
private BigDecimal baseAmount;
|
||||
private BigDecimal applyAmount;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PreviewResponse {
|
||||
private List<PreviewItem> items;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class BatchStatusResponse {
|
||||
private String batchId;
|
||||
private String idempotencyKey;
|
||||
private String status;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/preview", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:create')")
|
||||
public ResponseEntity<TypedR<PreviewResponse>> preview(@RequestBody DeductionRequest body) {
|
||||
if (body == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "body required"));
|
||||
}
|
||||
List<String> clerkIds = normalizeClerkIds(body.getClerkIds());
|
||||
if (clerkIds.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "clerkIds required"));
|
||||
}
|
||||
|
||||
LocalDateTime begin = parseDate(body.getBeginTime());
|
||||
LocalDateTime end = parseDate(body.getEndTime());
|
||||
if (begin == null || end == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "time range required"));
|
||||
}
|
||||
if (begin.isAfter(end)) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "beginTime must be <= endTime"));
|
||||
}
|
||||
|
||||
EarningsDeductionRuleType ruleType = body.getRuleType();
|
||||
if (ruleType == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleType required"));
|
||||
}
|
||||
EarningsDeductionOperationType operation = body.getOperation();
|
||||
if (operation == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "operation required"));
|
||||
}
|
||||
if (!StringUtils.hasText(body.getReasonDescription()) || !StringUtils.hasText(body.getReasonDescription().trim())) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonDescription required"));
|
||||
}
|
||||
|
||||
BigDecimal ruleValue = resolveRuleValue(ruleType, body.getAmount(), body.getPercentage());
|
||||
if (ruleValue == null || ruleValue.compareTo(BigDecimal.ZERO) == 0) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleValue must be non-zero"));
|
||||
}
|
||||
|
||||
enforceScope(clerkIds);
|
||||
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
PreviewResponse response = new PreviewResponse();
|
||||
List<PreviewItem> items = new ArrayList<>();
|
||||
for (String clerkId : clerkIds) {
|
||||
BigDecimal base = deductionItemMapper.sumOrderPositiveBase(tenantId, clerkId, begin, end);
|
||||
BigDecimal baseAmount = base == null ? BigDecimal.ZERO : base;
|
||||
BigDecimal applyAmount = calculateApplyAmount(ruleType, ruleValue, operation, baseAmount);
|
||||
PreviewItem item = new PreviewItem();
|
||||
item.setClerkId(clerkId);
|
||||
item.setBaseAmount(baseAmount.setScale(2, RoundingMode.HALF_UP));
|
||||
item.setApplyAmount(applyAmount.setScale(2, RoundingMode.HALF_UP));
|
||||
items.add(item);
|
||||
}
|
||||
response.setItems(items);
|
||||
return ResponseEntity.ok(TypedR.ok(response));
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:create')")
|
||||
public ResponseEntity<TypedR<BatchStatusResponse>> create(
|
||||
@RequestHeader(value = IDEMPOTENCY_HEADER, required = false) String idempotencyKey,
|
||||
@RequestBody DeductionRequest body) {
|
||||
|
||||
if (!StringUtils.hasText(idempotencyKey)) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "Idempotency-Key required"));
|
||||
}
|
||||
if (body == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "body required"));
|
||||
}
|
||||
List<String> clerkIds = normalizeClerkIds(body.getClerkIds());
|
||||
if (clerkIds.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "clerkIds required"));
|
||||
}
|
||||
|
||||
LocalDateTime begin = parseDate(body.getBeginTime());
|
||||
LocalDateTime end = parseDate(body.getEndTime());
|
||||
if (begin == null || end == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "time range required"));
|
||||
}
|
||||
if (begin.isAfter(end)) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "beginTime must be <= endTime"));
|
||||
}
|
||||
|
||||
if (body.getRuleType() == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleType required"));
|
||||
}
|
||||
if (body.getOperation() == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "operation required"));
|
||||
}
|
||||
if (!StringUtils.hasText(body.getReasonDescription()) || !StringUtils.hasText(body.getReasonDescription().trim())) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonDescription required"));
|
||||
}
|
||||
|
||||
BigDecimal ruleValue = resolveRuleValue(body.getRuleType(), body.getAmount(), body.getPercentage());
|
||||
if (ruleValue == null || ruleValue.compareTo(BigDecimal.ZERO) == 0) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleValue must be non-zero"));
|
||||
}
|
||||
|
||||
enforceScope(clerkIds);
|
||||
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "tenant missing"));
|
||||
}
|
||||
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch;
|
||||
try {
|
||||
batch = batchService.createOrGetProcessing(
|
||||
tenantId,
|
||||
clerkIds,
|
||||
begin,
|
||||
end,
|
||||
body.getRuleType(),
|
||||
ruleValue,
|
||||
body.getOperation(),
|
||||
body.getReasonDescription(),
|
||||
idempotencyKey);
|
||||
} catch (IllegalStateException conflict) {
|
||||
return ResponseEntity.status(409).body(TypedR.error(409, conflict.getMessage()));
|
||||
} catch (CustomException bad) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, bad.getMessage()));
|
||||
}
|
||||
|
||||
batchService.triggerApplyAsync(batch.getId());
|
||||
|
||||
BatchStatusResponse responseBody = new BatchStatusResponse();
|
||||
responseBody.setBatchId(batch.getId());
|
||||
responseBody.setIdempotencyKey(idempotencyKey);
|
||||
responseBody.setStatus(batch.getStatus() == null ? "PROCESSING" : batch.getStatus().name());
|
||||
|
||||
TypedR<BatchStatusResponse> result = TypedR.ok(responseBody);
|
||||
result.setCode(202);
|
||||
result.setMessage("请求处理中");
|
||||
return ResponseEntity.accepted()
|
||||
.header("Location", "/admin/earnings/deductions/idempotency/" + idempotencyKey)
|
||||
.body(result);
|
||||
}
|
||||
|
||||
@GetMapping("/idempotency/{key}")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
|
||||
public ResponseEntity<TypedR<BatchStatusResponse>> getByIdempotencyKey(@PathVariable("key") String key) {
|
||||
if (!StringUtils.hasText(key)) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.getByIdempotencyKey(tenantId, key);
|
||||
if (batch == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
enforceBatchScope(tenantId, batch.getId());
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity reconciled = batchService.reconcileAndGet(tenantId, batch.getId());
|
||||
if (reconciled == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
BatchStatusResponse responseBody = new BatchStatusResponse();
|
||||
responseBody.setBatchId(reconciled.getId());
|
||||
responseBody.setIdempotencyKey(key);
|
||||
responseBody.setStatus(reconciled.getStatus() == null ? "PROCESSING" : reconciled.getStatus().name());
|
||||
return ResponseEntity.ok(TypedR.ok(responseBody));
|
||||
}
|
||||
|
||||
@GetMapping("/{batchId}")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
|
||||
public ResponseEntity<TypedR<BatchStatusResponse>> getById(@PathVariable("batchId") String batchId) {
|
||||
if (!StringUtils.hasText(batchId)) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
|
||||
if (batch == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
enforceBatchScope(tenantId, batch.getId());
|
||||
BatchStatusResponse responseBody = new BatchStatusResponse();
|
||||
responseBody.setBatchId(batch.getId());
|
||||
responseBody.setIdempotencyKey(batch.getIdempotencyKey());
|
||||
responseBody.setStatus(batch.getStatus() == null ? "PROCESSING" : batch.getStatus().name());
|
||||
return ResponseEntity.ok(TypedR.ok(responseBody));
|
||||
}
|
||||
|
||||
@GetMapping("/{batchId}/items")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
|
||||
public ResponseEntity<TypedR<List<EarningsDeductionItemEntity>>> listItems(
|
||||
@PathVariable("batchId") String batchId,
|
||||
@RequestParam(value = "pageNum", required = false, defaultValue = "1") Integer pageNum,
|
||||
@RequestParam(value = "pageSize", required = false, defaultValue = "10") Integer pageSize) {
|
||||
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
|
||||
if (batch == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
enforceBatchScope(tenantId, batchId);
|
||||
IPage<EarningsDeductionItemEntity> page = batchService.pageItems(tenantId, batchId, pageNum, pageSize);
|
||||
return ResponseEntity.ok(TypedR.okPage(page));
|
||||
}
|
||||
|
||||
@GetMapping("/{batchId}/logs")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
|
||||
public ResponseEntity<TypedR<List<EarningsDeductionBatchLogEntity>>> listLogs(@PathVariable("batchId") String batchId) {
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
|
||||
if (batch == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
enforceBatchScope(tenantId, batchId);
|
||||
List<EarningsDeductionBatchLogEntity> logs = batchService.listLogs(tenantId, batchId);
|
||||
return ResponseEntity.ok(TypedR.ok(logs));
|
||||
}
|
||||
|
||||
@PostMapping("/{batchId}/retry")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:create')")
|
||||
public ResponseEntity<TypedR<Void>> retry(@PathVariable("batchId") String batchId) {
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
|
||||
if (batch == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
enforceBatchScope(tenantId, batchId);
|
||||
batchService.retryAsync(batchId);
|
||||
return ResponseEntity.ok(TypedR.ok(null));
|
||||
}
|
||||
|
||||
private void enforceScope(List<String> clerkIds) {
|
||||
for (String clerkId : clerkIds) {
|
||||
if (!earningsAuth.canManageClerk(clerkId)) {
|
||||
throw new AccessDeniedException("forbidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void enforceBatchScope(String tenantId, String batchId) {
|
||||
if (SecurityUtils.getLoginUser() != null
|
||||
&& SecurityUtils.getLoginUser().getUser() != null
|
||||
&& SecurityUtils.isAdmin(SecurityUtils.getLoginUser().getUser())) {
|
||||
return;
|
||||
}
|
||||
List<EarningsDeductionItemEntity> items = batchService.listItems(tenantId, batchId);
|
||||
for (EarningsDeductionItemEntity item : items) {
|
||||
if (item != null && StringUtils.hasText(item.getClerkId())) {
|
||||
if (!earningsAuth.canManageClerk(item.getClerkId())) {
|
||||
throw new AccessDeniedException("forbidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDateTime parseDate(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDateTime.parse(value.trim(), SIMPLE_DATE_TIME);
|
||||
} catch (DateTimeParseException ex) {
|
||||
throw new CustomException("invalid datetime: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal resolveRuleValue(EarningsDeductionRuleType type, BigDecimal amount, BigDecimal percentage) {
|
||||
if (type == EarningsDeductionRuleType.FIXED) {
|
||||
return amount;
|
||||
}
|
||||
return percentage;
|
||||
}
|
||||
|
||||
private BigDecimal calculateApplyAmount(EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
BigDecimal baseAmount) {
|
||||
BigDecimal resolvedBase = baseAmount == null ? BigDecimal.ZERO : baseAmount;
|
||||
BigDecimal resolvedRule = ruleValue == null ? BigDecimal.ZERO : ruleValue;
|
||||
BigDecimal amount;
|
||||
if (ruleType == EarningsDeductionRuleType.FIXED) {
|
||||
amount = resolvedRule;
|
||||
} else {
|
||||
amount = resolvedBase.multiply(resolvedRule).divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
|
||||
}
|
||||
amount = amount.setScale(2, RoundingMode.HALF_UP);
|
||||
if (operation == EarningsDeductionOperationType.PUNISHMENT) {
|
||||
return amount.abs().negate();
|
||||
}
|
||||
return amount.abs();
|
||||
}
|
||||
|
||||
private List<String> normalizeClerkIds(List<String> input) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (CollectionUtils.isEmpty(input)) {
|
||||
return result;
|
||||
}
|
||||
for (String id : input) {
|
||||
if (!StringUtils.hasText(id)) {
|
||||
continue;
|
||||
}
|
||||
String trimmed = id.trim();
|
||||
if (!trimmed.isEmpty() && !result.contains(trimmed)) {
|
||||
result.add(trimmed);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,9 @@ import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Api(tags = "提现管理-后台")
|
||||
@@ -195,6 +198,36 @@ public class AdminWithdrawalController {
|
||||
return TypedR.ok(vos);
|
||||
}
|
||||
|
||||
public static class RejectWithdrawalRequest {
|
||||
private String reason;
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
@ApiOperation("拒绝/取消提现请求(释放已预留收益)")
|
||||
@PostMapping("/requests/{id}/reject")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:request:reject') and @earningsAuth.canRejectWithdrawal(#id)")
|
||||
public ResponseEntity<TypedR<Void>> reject(@PathVariable("id") String id, @RequestBody(required = false) RejectWithdrawalRequest body) {
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
WithdrawalRequestEntity req = withdrawalService.getById(id);
|
||||
if (req == null || !tenantId.equals(req.getTenantId())) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(TypedR.error(404, "请求不存在"));
|
||||
}
|
||||
String reason = body == null ? null : body.getReason();
|
||||
try {
|
||||
withdrawalService.reject(req.getId(), reason);
|
||||
} catch (CustomException ex) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(TypedR.error(400, ex.getMessage()));
|
||||
}
|
||||
return ResponseEntity.ok(TypedR.ok(null));
|
||||
}
|
||||
|
||||
@ApiOperation("分页查询收益明细")
|
||||
@PostMapping("/earnings/listByPage")
|
||||
public TypedR<List<EarningsLineEntity>> listEarnings(@RequestBody EarningsAdminQueryVo vo) {
|
||||
|
||||
@@ -8,14 +8,19 @@ import com.starry.admin.common.conf.ThreadLocalRequestDetail;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.WithdrawalRequestStatus;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalLogService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.modules.withdraw.vo.ClerkEarningLineVo;
|
||||
import com.starry.admin.modules.withdraw.vo.ClerkWithdrawBalanceVo;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.result.TypedR;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -40,6 +45,8 @@ public class WxWithdrawController {
|
||||
@Resource
|
||||
private IEarningsService earningsService;
|
||||
@Resource
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
@Resource
|
||||
private IWithdrawalService withdrawalService;
|
||||
@Resource
|
||||
private IWithdrawalLogService withdrawalLogService;
|
||||
@@ -55,11 +62,43 @@ public class WxWithdrawController {
|
||||
@GetMapping("/balance")
|
||||
public TypedR<ClerkWithdrawBalanceVo> getBalance() {
|
||||
String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
BigDecimal available = earningsService.getAvailableAmount(clerkId, now);
|
||||
BigDecimal pending = earningsService.getPendingAmount(clerkId, now);
|
||||
LocalDateTime nextUnlock = earningsService.getNextUnlockTime(clerkId, now);
|
||||
return TypedR.ok(new ClerkWithdrawBalanceVo(available, pending, nextUnlock));
|
||||
WithdrawalRequestEntity active = withdrawalService.lambdaQuery()
|
||||
.eq(WithdrawalRequestEntity::getTenantId, tenantId)
|
||||
.eq(WithdrawalRequestEntity::getClerkId, clerkId)
|
||||
.in(WithdrawalRequestEntity::getStatus,
|
||||
WithdrawalRequestStatus.PENDING.getCode(),
|
||||
WithdrawalRequestStatus.PROCESSING.getCode())
|
||||
.orderByDesc(WithdrawalRequestEntity::getCreatedTime)
|
||||
.last("limit 1")
|
||||
.one();
|
||||
|
||||
Boolean locked = active != null;
|
||||
String lockReason = locked ? "当前已有一笔提现申请在途,请等待处理完成后再申请。" : "";
|
||||
ClerkWithdrawBalanceVo.ActiveRequest activeRequest = null;
|
||||
if (active != null) {
|
||||
Date createdTime = active.getCreatedTime();
|
||||
LocalDateTime createdAt = createdTime == null ? null
|
||||
: LocalDateTime.ofInstant(createdTime.toInstant(), ZoneId.systemDefault());
|
||||
activeRequest = ClerkWithdrawBalanceVo.ActiveRequest.builder()
|
||||
.amount(active.getAmount())
|
||||
.status(active.getStatus())
|
||||
.createdTime(createdAt == null ? "" : createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
|
||||
.build();
|
||||
}
|
||||
|
||||
ClerkWithdrawBalanceVo vo = new ClerkWithdrawBalanceVo();
|
||||
vo.setAvailable(available);
|
||||
vo.setPending(pending);
|
||||
vo.setNextUnlockAt(nextUnlock);
|
||||
vo.setWithdrawLocked(locked);
|
||||
vo.setWithdrawLockReason(lockReason);
|
||||
vo.setActiveRequest(activeRequest);
|
||||
return TypedR.ok(vo);
|
||||
}
|
||||
|
||||
@ClerkUserLogin
|
||||
@@ -101,6 +140,21 @@ public class WxWithdrawController {
|
||||
.list()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(PlayOrderInfoEntity::getId, it -> it));
|
||||
|
||||
List<String> adjustmentIds = records.stream()
|
||||
.filter(line -> line.getSourceType() == EarningsSourceType.ADJUSTMENT)
|
||||
.map(EarningsLineEntity::getSourceId)
|
||||
.filter(id -> id != null && !id.isEmpty())
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
Map<String, EarningsLineAdjustmentEntity> adjustmentMap = adjustmentIds.isEmpty() ? java.util.Collections.emptyMap()
|
||||
: adjustmentService.lambdaQuery()
|
||||
.eq(EarningsLineAdjustmentEntity::getTenantId, SecurityUtils.getTenantId())
|
||||
.in(EarningsLineAdjustmentEntity::getId, adjustmentIds)
|
||||
.list()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(EarningsLineAdjustmentEntity::getId, it -> it));
|
||||
|
||||
for (EarningsLineEntity line : records) {
|
||||
ClerkEarningLineVo vo = new ClerkEarningLineVo();
|
||||
vo.setId(line.getId());
|
||||
@@ -111,6 +165,14 @@ public class WxWithdrawController {
|
||||
vo.setUnlockTime(line.getUnlockTime());
|
||||
vo.setCreatedTime(toLocalDateTime(line.getCreatedTime()));
|
||||
vo.setOrderId(line.getOrderId());
|
||||
if (line.getSourceType() == EarningsSourceType.ADJUSTMENT && line.getSourceId() != null) {
|
||||
EarningsLineAdjustmentEntity adjustment = adjustmentMap.get(line.getSourceId());
|
||||
if (adjustment != null) {
|
||||
vo.setAdjustmentReasonType(adjustment.getReasonType());
|
||||
vo.setAdjustmentReasonDescription(adjustment.getReasonDescription());
|
||||
vo.setAdjustmentEffectiveTime(adjustment.getEffectiveTime());
|
||||
}
|
||||
}
|
||||
if (line.getOrderId() != null) {
|
||||
PlayOrderInfoEntity order = orderMap.get(line.getOrderId());
|
||||
if (order != null) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionStatus;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("play_earnings_deduction_batch")
|
||||
public class EarningsDeductionBatchEntity extends BaseEntity<EarningsDeductionBatchEntity> {
|
||||
private String id;
|
||||
private String tenantId;
|
||||
private EarningsDeductionStatus status;
|
||||
|
||||
@TableField("begin_time")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime windowBeginTime;
|
||||
|
||||
@TableField("end_time")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime windowEndTime;
|
||||
|
||||
private EarningsDeductionRuleType ruleType;
|
||||
private BigDecimal ruleValue;
|
||||
private EarningsDeductionOperationType operation;
|
||||
private String reasonDescription;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionLogEventType;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("play_earnings_deduction_batch_log")
|
||||
public class EarningsDeductionBatchLogEntity extends BaseEntity<EarningsDeductionBatchLogEntity> {
|
||||
private String id;
|
||||
private String batchId;
|
||||
private String tenantId;
|
||||
private EarningsDeductionLogEventType eventType;
|
||||
private String statusFrom;
|
||||
private String statusTo;
|
||||
private String message;
|
||||
private String payload;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionStatus;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("play_earnings_deduction_item")
|
||||
public class EarningsDeductionItemEntity extends BaseEntity<EarningsDeductionItemEntity> {
|
||||
private String id;
|
||||
private String batchId;
|
||||
private String tenantId;
|
||||
private String clerkId;
|
||||
private BigDecimal baseAmount;
|
||||
private BigDecimal applyAmount;
|
||||
private EarningsDeductionStatus status;
|
||||
private String adjustmentId;
|
||||
private String failureReason;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("play_earnings_line_adjustment")
|
||||
public class EarningsLineAdjustmentEntity extends BaseEntity<EarningsLineAdjustmentEntity> {
|
||||
private String id;
|
||||
private String tenantId;
|
||||
private String clerkId;
|
||||
private BigDecimal amount;
|
||||
private EarningsAdjustmentReasonType reasonType;
|
||||
private String reasonDescription;
|
||||
private EarningsAdjustmentStatus status;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime effectiveTime;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime appliedTime;
|
||||
|
||||
private String failureReason;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import java.math.BigDecimal;
|
||||
@@ -18,6 +19,15 @@ public class EarningsLineEntity extends BaseEntity<EarningsLineEntity> {
|
||||
private String tenantId;
|
||||
private String clerkId;
|
||||
private String orderId;
|
||||
/**
|
||||
* Source identity for ledger line.
|
||||
*
|
||||
* <p>ORDER: sourceId == orderId (non-null).
|
||||
* <p>ADJUSTMENT: sourceId == adjustmentId, and orderId should be null.
|
||||
*/
|
||||
private EarningsSourceType sourceType;
|
||||
|
||||
private String sourceId;
|
||||
private BigDecimal amount;
|
||||
private EarningsType earningType;
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/**
|
||||
* Reason type for adjustments (hard-coded for now).
|
||||
*/
|
||||
public enum EarningsAdjustmentReasonType {
|
||||
MANUAL("MANUAL");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsAdjustmentReasonType(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum EarningsAdjustmentStatus {
|
||||
PROCESSING("PROCESSING"),
|
||||
APPLIED("APPLIED"),
|
||||
FAILED("FAILED");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsAdjustmentStatus(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum EarningsDeductionLogEventType {
|
||||
CREATED("CREATED"),
|
||||
APPLY_STARTED("APPLY_STARTED"),
|
||||
ITEM_APPLIED("ITEM_APPLIED"),
|
||||
ITEM_FAILED("ITEM_FAILED"),
|
||||
BATCH_APPLIED("BATCH_APPLIED"),
|
||||
BATCH_FAILED("BATCH_FAILED"),
|
||||
RETRY_STARTED("RETRY_STARTED");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsDeductionLogEventType(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum EarningsDeductionOperationType {
|
||||
BONUS("BONUS"),
|
||||
PUNISHMENT("PUNISHMENT");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsDeductionOperationType(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum EarningsDeductionRuleType {
|
||||
FIXED("FIXED"),
|
||||
PERCENTAGE("PERCENTAGE");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsDeductionRuleType(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum EarningsDeductionStatus {
|
||||
PROCESSING("PROCESSING"),
|
||||
APPLIED("APPLIED"),
|
||||
FAILED("FAILED");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsDeductionStatus(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public enum EarningsLineStatus {
|
||||
FROZEN("frozen"),
|
||||
AVAILABLE("available"),
|
||||
WITHDRAWING("withdrawing"),
|
||||
WITHDRAWN("withdrawn"),
|
||||
REVERSED("reversed");
|
||||
|
||||
private final String code;
|
||||
|
||||
EarningsLineStatus(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static EarningsLineStatus fromCode(String code) {
|
||||
return Arrays.stream(values())
|
||||
.filter(it -> it.code.equals(code))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("invalid earnings line status: " + code));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/**
|
||||
* Earnings line source type.
|
||||
*/
|
||||
public enum EarningsSourceType {
|
||||
ORDER("ORDER"),
|
||||
ADJUSTMENT("ADJUSTMENT");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsSourceType(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public enum WithdrawalRequestStatus {
|
||||
PENDING("pending"),
|
||||
PROCESSING("processing"),
|
||||
SUCCESS("success"),
|
||||
FAILED("failed"),
|
||||
CANCELED("canceled"),
|
||||
REJECTED("rejected");
|
||||
|
||||
private final String code;
|
||||
|
||||
WithdrawalRequestStatus(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static WithdrawalRequestStatus fromCode(String code) {
|
||||
return Arrays.stream(values())
|
||||
.filter(it -> it.code.equals(code))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("invalid withdrawal request status: " + code));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface EarningsDeductionBatchLogMapper extends BaseMapper<EarningsDeductionBatchLogEntity> {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface EarningsDeductionBatchMapper extends BaseMapper<EarningsDeductionBatchEntity> {}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface EarningsDeductionItemMapper extends BaseMapper<EarningsDeductionItemEntity> {
|
||||
|
||||
@Select("<script>" +
|
||||
"SELECT COALESCE(SUM(el.amount), 0) " +
|
||||
"FROM play_earnings_line el " +
|
||||
"JOIN play_order_info oi ON oi.id = el.order_id " +
|
||||
"WHERE el.deleted = 0 " +
|
||||
" AND oi.deleted = 0 " +
|
||||
" AND el.tenant_id = #{tenantId} " +
|
||||
" AND el.clerk_id = #{clerkId} " +
|
||||
" AND el.order_id IS NOT NULL " +
|
||||
" AND el.amount > 0 " +
|
||||
" AND el.status <> 'reversed' " +
|
||||
" AND oi.order_end_time IS NOT NULL " +
|
||||
" AND oi.order_end_time >= #{begin} " +
|
||||
" AND oi.order_end_time <= #{end} " +
|
||||
"</script>")
|
||||
BigDecimal sumOrderPositiveBase(@Param("tenantId") String tenantId,
|
||||
@Param("clerkId") String clerkId,
|
||||
@Param("begin") LocalDateTime begin,
|
||||
@Param("end") LocalDateTime end);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface EarningsLineAdjustmentMapper extends BaseMapper<EarningsLineAdjustmentEntity> {}
|
||||
@@ -59,4 +59,24 @@ public interface EarningsLineMapper extends BaseMapper<EarningsLineEntity> {
|
||||
@Param("orderIds") Collection<String> orderIds,
|
||||
@Param("startTime") String startTime,
|
||||
@Param("endTime") String endTime);
|
||||
|
||||
@Select("<script>" +
|
||||
"SELECT COALESCE(SUM(amount), 0) " +
|
||||
"FROM play_earnings_line " +
|
||||
"WHERE deleted = 0 " +
|
||||
" AND tenant_id = #{tenantId} " +
|
||||
" AND clerk_id = #{clerkId} " +
|
||||
" AND source_type = 'ADJUSTMENT' " +
|
||||
" AND source_id IS NOT NULL " +
|
||||
"<if test='startTime != null'>" +
|
||||
" AND unlock_time >= #{startTime}" +
|
||||
"</if>" +
|
||||
"<if test='endTime != null'>" +
|
||||
" AND unlock_time <= #{endTime}" +
|
||||
"</if>" +
|
||||
"</script>")
|
||||
BigDecimal sumAdjustmentsByClerk(@Param("tenantId") String tenantId,
|
||||
@Param("clerkId") String clerkId,
|
||||
@Param("startTime") String startTime,
|
||||
@Param("endTime") String endTime);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.starry.admin.modules.withdraw.security;
|
||||
|
||||
import com.starry.admin.common.domain.LoginUser;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
|
||||
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service("earningsAuth")
|
||||
public class EarningsAuthorizationService {
|
||||
|
||||
private final IPlayClerkUserInfoService clerkUserInfoService;
|
||||
private final IPlayPersonnelGroupInfoService groupInfoService;
|
||||
private final IEarningsAdjustmentService earningsAdjustmentService;
|
||||
private final IWithdrawalService withdrawalService;
|
||||
|
||||
public EarningsAuthorizationService(
|
||||
IPlayClerkUserInfoService clerkUserInfoService,
|
||||
IPlayPersonnelGroupInfoService groupInfoService,
|
||||
IEarningsAdjustmentService earningsAdjustmentService,
|
||||
IWithdrawalService withdrawalService) {
|
||||
this.clerkUserInfoService = clerkUserInfoService;
|
||||
this.groupInfoService = groupInfoService;
|
||||
this.earningsAdjustmentService = earningsAdjustmentService;
|
||||
this.withdrawalService = withdrawalService;
|
||||
}
|
||||
|
||||
public boolean canManageClerk(String clerkId) {
|
||||
if (!StringUtils.hasText(clerkId)) {
|
||||
return false;
|
||||
}
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
if (loginUser == null || loginUser.getUser() == null) {
|
||||
return false;
|
||||
}
|
||||
if (SecurityUtils.isAdmin(loginUser.getUser())) {
|
||||
return true;
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(clerkId);
|
||||
if (clerk == null || !tenantId.equals(clerk.getTenantId())) {
|
||||
return false;
|
||||
}
|
||||
if (!StringUtils.hasText(clerk.getGroupId())) {
|
||||
return false;
|
||||
}
|
||||
PlayPersonnelGroupInfoEntity group = groupInfoService.getById(clerk.getGroupId());
|
||||
if (group == null || !tenantId.equals(group.getTenantId())) {
|
||||
return false;
|
||||
}
|
||||
return loginUser.getUserId() != null && loginUser.getUserId().equals(group.getSysUserId());
|
||||
}
|
||||
|
||||
public boolean canReadAdjustmentByIdempotencyKey(String key) {
|
||||
if (!StringUtils.hasText(key)) {
|
||||
return false;
|
||||
}
|
||||
if (isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
return false;
|
||||
}
|
||||
EarningsLineAdjustmentEntity adjustment = earningsAdjustmentService.getByIdempotencyKey(tenantId, key);
|
||||
if (adjustment == null) {
|
||||
return true;
|
||||
}
|
||||
return canManageClerk(adjustment.getClerkId());
|
||||
}
|
||||
|
||||
public boolean canRejectWithdrawal(String requestId) {
|
||||
if (!StringUtils.hasText(requestId)) {
|
||||
return false;
|
||||
}
|
||||
if (isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
WithdrawalRequestEntity req = withdrawalService.getById(requestId);
|
||||
if (req == null) {
|
||||
return true;
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
return false;
|
||||
}
|
||||
if (!tenantId.equals(req.getTenantId())) {
|
||||
return true;
|
||||
}
|
||||
return canManageClerk(req.getClerkId());
|
||||
}
|
||||
|
||||
private boolean isSuperAdmin() {
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
return loginUser != null && loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.starry.admin.modules.withdraw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public interface IEarningsAdjustmentService extends IService<EarningsLineAdjustmentEntity> {
|
||||
|
||||
EarningsLineAdjustmentEntity createOrGetProcessing(
|
||||
String tenantId,
|
||||
String clerkId,
|
||||
BigDecimal amount,
|
||||
EarningsAdjustmentReasonType reasonType,
|
||||
String reasonDescription,
|
||||
String idempotencyKey,
|
||||
LocalDateTime effectiveTime);
|
||||
|
||||
EarningsLineAdjustmentEntity getByIdempotencyKey(String tenantId, String idempotencyKey);
|
||||
|
||||
void triggerApplyAsync(String adjustmentId);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.starry.admin.modules.withdraw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface IEarningsDeductionBatchService extends IService<EarningsDeductionBatchEntity> {
|
||||
|
||||
EarningsDeductionBatchEntity createOrGetProcessing(
|
||||
String tenantId,
|
||||
List<String> clerkIds,
|
||||
LocalDateTime beginTime,
|
||||
LocalDateTime endTime,
|
||||
EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
String reasonDescription,
|
||||
String idempotencyKey);
|
||||
|
||||
EarningsDeductionBatchEntity getByIdempotencyKey(String tenantId, String idempotencyKey);
|
||||
|
||||
List<EarningsDeductionItemEntity> listItems(String tenantId, String batchId);
|
||||
|
||||
IPage<EarningsDeductionItemEntity> pageItems(String tenantId, String batchId, int pageNum, int pageSize);
|
||||
|
||||
List<EarningsDeductionBatchLogEntity> listLogs(String tenantId, String batchId);
|
||||
|
||||
void triggerApplyAsync(String batchId);
|
||||
|
||||
void retryAsync(String batchId);
|
||||
|
||||
EarningsDeductionBatchEntity reconcileAndGet(String tenantId, String batchId);
|
||||
}
|
||||
@@ -10,4 +10,6 @@ public interface IWithdrawalService extends IService<WithdrawalRequestEntity> {
|
||||
void markManualSuccess(String requestId, String operatorBy);
|
||||
|
||||
void autoPayout(String requestId);
|
||||
|
||||
void reject(String requestId, String reason);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.starry.admin.modules.withdraw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsLineAdjustmentMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class EarningsAdjustmentServiceImpl extends ServiceImpl<EarningsLineAdjustmentMapper, EarningsLineAdjustmentEntity>
|
||||
implements IEarningsAdjustmentService {
|
||||
|
||||
@Resource
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Resource(name = "threadPoolTaskExecutor")
|
||||
private ThreadPoolTaskExecutor executor;
|
||||
|
||||
@Override
|
||||
public EarningsLineAdjustmentEntity createOrGetProcessing(
|
||||
String tenantId,
|
||||
String clerkId,
|
||||
BigDecimal amount,
|
||||
EarningsAdjustmentReasonType reasonType,
|
||||
String reasonDescription,
|
||||
String idempotencyKey,
|
||||
LocalDateTime effectiveTime) {
|
||||
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(clerkId)) {
|
||||
throw new CustomException("参数缺失");
|
||||
}
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) == 0) {
|
||||
throw new CustomException("amount 必须非0");
|
||||
}
|
||||
if (reasonType == null) {
|
||||
throw new CustomException("reasonType 必填");
|
||||
}
|
||||
if (!StringUtils.hasText(idempotencyKey)) {
|
||||
throw new CustomException("Idempotency-Key 必填");
|
||||
}
|
||||
String trimmedReason = reasonDescription == null ? "" : reasonDescription.trim();
|
||||
if (!StringUtils.hasText(trimmedReason)) {
|
||||
throw new CustomException("reasonDescription 必填");
|
||||
}
|
||||
|
||||
LocalDateTime resolvedEffective = effectiveTime == null ? LocalDateTime.now() : effectiveTime;
|
||||
// Idempotency hash must be stable across retries; do NOT inject server "now" into the hash.
|
||||
// If client omits effectiveTime, we treat it as "unspecified" for hashing.
|
||||
String requestHash = computeRequestHash(tenantId, clerkId, amount, reasonType, trimmedReason, effectiveTime);
|
||||
|
||||
EarningsLineAdjustmentEntity existing = getByIdempotencyKey(tenantId, idempotencyKey);
|
||||
if (existing != null) {
|
||||
if (existing.getRequestHash() != null && !existing.getRequestHash().equals(requestHash)) {
|
||||
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
EarningsLineAdjustmentEntity created = new EarningsLineAdjustmentEntity();
|
||||
created.setId(IdUtils.getUuid());
|
||||
created.setTenantId(tenantId);
|
||||
created.setClerkId(clerkId);
|
||||
created.setAmount(amount);
|
||||
created.setReasonType(reasonType);
|
||||
created.setReasonDescription(trimmedReason);
|
||||
created.setStatus(EarningsAdjustmentStatus.PROCESSING);
|
||||
created.setIdempotencyKey(idempotencyKey);
|
||||
created.setRequestHash(requestHash);
|
||||
created.setEffectiveTime(resolvedEffective);
|
||||
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
|
||||
created.setCreatedTime(now);
|
||||
created.setUpdatedTime(now);
|
||||
|
||||
try {
|
||||
this.save(created);
|
||||
return created;
|
||||
} catch (DuplicateKeyException dup) {
|
||||
EarningsLineAdjustmentEntity raced = getByIdempotencyKey(tenantId, idempotencyKey);
|
||||
if (raced == null) {
|
||||
throw dup;
|
||||
}
|
||||
if (raced.getRequestHash() != null && !raced.getRequestHash().equals(requestHash)) {
|
||||
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
|
||||
}
|
||||
return raced;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public EarningsLineAdjustmentEntity getByIdempotencyKey(String tenantId, String idempotencyKey) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(idempotencyKey)) {
|
||||
return null;
|
||||
}
|
||||
return this.getOne(Wrappers.lambdaQuery(EarningsLineAdjustmentEntity.class)
|
||||
.eq(EarningsLineAdjustmentEntity::getTenantId, tenantId)
|
||||
.eq(EarningsLineAdjustmentEntity::getIdempotencyKey, idempotencyKey)
|
||||
.eq(EarningsLineAdjustmentEntity::getDeleted, false)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void triggerApplyAsync(String adjustmentId) {
|
||||
if (!StringUtils.hasText(adjustmentId)) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
applyOnce(adjustmentId);
|
||||
} catch (Exception ignored) {
|
||||
// Intentionally swallow: async apply must not crash request thread.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void applyOnce(String adjustmentId) {
|
||||
EarningsLineAdjustmentEntity adjustment = this.getById(adjustmentId);
|
||||
if (adjustment == null) {
|
||||
return;
|
||||
}
|
||||
if (adjustment.getStatus() == EarningsAdjustmentStatus.APPLIED) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
EarningsLineEntity line = new EarningsLineEntity();
|
||||
line.setId(IdUtils.getUuid());
|
||||
line.setTenantId(adjustment.getTenantId());
|
||||
line.setClerkId(adjustment.getClerkId());
|
||||
line.setOrderId(null);
|
||||
line.setSourceType(EarningsSourceType.ADJUSTMENT);
|
||||
line.setSourceId(adjustment.getId());
|
||||
line.setAmount(adjustment.getAmount());
|
||||
line.setEarningType(EarningsType.ADJUSTMENT);
|
||||
line.setStatus("available");
|
||||
LocalDateTime effective = adjustment.getEffectiveTime() == null ? LocalDateTime.now() : adjustment.getEffectiveTime();
|
||||
line.setUnlockTime(effective);
|
||||
Date now = new Date();
|
||||
line.setCreatedTime(now);
|
||||
line.setUpdatedTime(now);
|
||||
line.setDeleted(false);
|
||||
|
||||
earningsService.save(line);
|
||||
|
||||
EarningsLineAdjustmentEntity update = new EarningsLineAdjustmentEntity();
|
||||
update.setId(adjustment.getId());
|
||||
update.setStatus(EarningsAdjustmentStatus.APPLIED);
|
||||
update.setAppliedTime(LocalDateTime.now());
|
||||
this.updateById(update);
|
||||
} catch (DuplicateKeyException dup) {
|
||||
EarningsLineAdjustmentEntity update = new EarningsLineAdjustmentEntity();
|
||||
update.setId(adjustment.getId());
|
||||
update.setStatus(EarningsAdjustmentStatus.APPLIED);
|
||||
update.setAppliedTime(LocalDateTime.now());
|
||||
this.updateById(update);
|
||||
} catch (Exception ex) {
|
||||
EarningsLineAdjustmentEntity update = new EarningsLineAdjustmentEntity();
|
||||
update.setId(adjustment.getId());
|
||||
update.setStatus(EarningsAdjustmentStatus.FAILED);
|
||||
update.setFailureReason(ex.getMessage());
|
||||
this.updateById(update);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
private String computeRequestHash(
|
||||
String tenantId,
|
||||
String clerkId,
|
||||
BigDecimal amount,
|
||||
EarningsAdjustmentReasonType reasonType,
|
||||
String reasonDescription,
|
||||
LocalDateTime effectiveTime) {
|
||||
String normalizedAmount = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
String rawEffective = effectiveTime == null ? "" : effectiveTime.toString();
|
||||
String raw = tenantId + "|" + clerkId + "|" + normalizedAmount + "|" + reasonType.name() + "|" + reasonDescription + "|" + rawEffective;
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
return toHex(bytes);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("hash failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String toHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
package com.starry.admin.modules.withdraw.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionLogEventType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionStatus;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionBatchLogMapper;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionBatchMapper;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionItemMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsDeductionBatchService;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class EarningsDeductionBatchServiceImpl
|
||||
extends ServiceImpl<EarningsDeductionBatchMapper, EarningsDeductionBatchEntity>
|
||||
implements IEarningsDeductionBatchService {
|
||||
|
||||
@Resource
|
||||
private EarningsDeductionItemMapper itemMapper;
|
||||
|
||||
@Resource
|
||||
private EarningsDeductionBatchLogMapper logMapper;
|
||||
|
||||
@Resource
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
@Resource(name = "threadPoolTaskExecutor")
|
||||
private ThreadPoolTaskExecutor executor;
|
||||
|
||||
@Override
|
||||
public EarningsDeductionBatchEntity createOrGetProcessing(
|
||||
String tenantId,
|
||||
List<String> clerkIds,
|
||||
LocalDateTime beginTime,
|
||||
LocalDateTime endTime,
|
||||
EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
String reasonDescription,
|
||||
String idempotencyKey) {
|
||||
|
||||
validateCreateRequest(tenantId, clerkIds, beginTime, endTime, ruleType, ruleValue, operation, reasonDescription, idempotencyKey);
|
||||
|
||||
List<String> normalizedClerks = normalizeClerkIds(clerkIds);
|
||||
String hash = computeRequestHash(tenantId, normalizedClerks, beginTime, endTime, ruleType, ruleValue, operation, reasonDescription);
|
||||
|
||||
EarningsDeductionBatchEntity existing = getByIdempotencyKey(tenantId, idempotencyKey);
|
||||
if (existing != null) {
|
||||
if (existing.getRequestHash() != null && !existing.getRequestHash().equals(hash)) {
|
||||
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
EarningsDeductionBatchEntity created = new EarningsDeductionBatchEntity();
|
||||
created.setId(IdUtils.getUuid());
|
||||
created.setTenantId(tenantId);
|
||||
created.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
created.setWindowBeginTime(beginTime);
|
||||
created.setWindowEndTime(endTime);
|
||||
created.setRuleType(ruleType);
|
||||
created.setRuleValue(ruleValue.setScale(2, RoundingMode.HALF_UP));
|
||||
created.setOperation(operation);
|
||||
created.setReasonDescription(reasonDescription.trim());
|
||||
created.setIdempotencyKey(idempotencyKey);
|
||||
created.setRequestHash(hash);
|
||||
Date now = new Date();
|
||||
created.setCreatedTime(now);
|
||||
created.setUpdatedTime(now);
|
||||
created.setDeleted(false);
|
||||
|
||||
try {
|
||||
this.save(created);
|
||||
ensureItems(created, normalizedClerks);
|
||||
logOnce(created, EarningsDeductionLogEventType.CREATED, null, EarningsDeductionStatus.PROCESSING.name(),
|
||||
"批次已创建", buildPayload(created, normalizedClerks));
|
||||
return created;
|
||||
} catch (DuplicateKeyException dup) {
|
||||
EarningsDeductionBatchEntity raced = getByIdempotencyKey(tenantId, idempotencyKey);
|
||||
if (raced == null) {
|
||||
throw dup;
|
||||
}
|
||||
if (raced.getRequestHash() != null && !raced.getRequestHash().equals(hash)) {
|
||||
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
|
||||
}
|
||||
ensureItems(raced, normalizedClerks);
|
||||
logOnce(raced, EarningsDeductionLogEventType.CREATED, null, EarningsDeductionStatus.PROCESSING.name(),
|
||||
"批次已创建", buildPayload(raced, normalizedClerks));
|
||||
return raced;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public EarningsDeductionBatchEntity getByIdempotencyKey(String tenantId, String idempotencyKey) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(idempotencyKey)) {
|
||||
return null;
|
||||
}
|
||||
return this.getOne(Wrappers.lambdaQuery(EarningsDeductionBatchEntity.class)
|
||||
.eq(EarningsDeductionBatchEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionBatchEntity::getIdempotencyKey, idempotencyKey)
|
||||
.eq(EarningsDeductionBatchEntity::getDeleted, false)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<EarningsDeductionItemEntity> listItems(String tenantId, String batchId) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batchId)
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false)
|
||||
.orderByAsc(EarningsDeductionItemEntity::getCreatedTime));
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<EarningsDeductionItemEntity> pageItems(String tenantId, String batchId, int pageNum, int pageSize) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
|
||||
return new Page<>(pageNum, pageSize);
|
||||
}
|
||||
return itemMapper.selectPage(new Page<>(pageNum, pageSize), Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batchId)
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false)
|
||||
.orderByAsc(EarningsDeductionItemEntity::getCreatedTime));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<EarningsDeductionBatchLogEntity> listLogs(String tenantId, String batchId) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return logMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionBatchLogEntity.class)
|
||||
.eq(EarningsDeductionBatchLogEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionBatchLogEntity::getBatchId, batchId)
|
||||
.eq(EarningsDeductionBatchLogEntity::getDeleted, false)
|
||||
.orderByAsc(EarningsDeductionBatchLogEntity::getCreatedTime));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void triggerApplyAsync(String batchId) {
|
||||
if (!StringUtils.hasText(batchId)) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
applyOnce(batchId);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void retryAsync(String batchId) {
|
||||
if (!StringUtils.hasText(batchId)) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
retryOnce(batchId);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public EarningsDeductionBatchEntity reconcileAndGet(String tenantId, String batchId) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
|
||||
return null;
|
||||
}
|
||||
EarningsDeductionBatchEntity batch = this.getById(batchId);
|
||||
if (batch == null || !tenantId.equals(batch.getTenantId())) {
|
||||
return null;
|
||||
}
|
||||
reconcile(batch);
|
||||
return this.getById(batchId);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void applyOnce(String batchId) {
|
||||
EarningsDeductionBatchEntity batch = this.getById(batchId);
|
||||
if (batch == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
logOnce(batch, EarningsDeductionLogEventType.APPLY_STARTED, null, EarningsDeductionStatus.PROCESSING.name(),
|
||||
"开始执行批次", null);
|
||||
|
||||
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, batch.getTenantId())
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batch.getId())
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false));
|
||||
if (items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (EarningsDeductionItemEntity item : items) {
|
||||
if (item == null || !StringUtils.hasText(item.getClerkId())) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.hasText(item.getAdjustmentId())) {
|
||||
adjustmentService.triggerApplyAsync(item.getAdjustmentId());
|
||||
continue;
|
||||
}
|
||||
BigDecimal amount = item.getApplyAmount() == null ? BigDecimal.ZERO : item.getApplyAmount();
|
||||
if (amount.compareTo(BigDecimal.ZERO) == 0) {
|
||||
markItemFailed(item.getId(), "applyAmount=0");
|
||||
continue;
|
||||
}
|
||||
String idempotencyKey = "deduct:" + batch.getId() + ":" + item.getClerkId();
|
||||
EarningsLineAdjustmentEntity adjustment = adjustmentService.createOrGetProcessing(
|
||||
batch.getTenantId(),
|
||||
item.getClerkId(),
|
||||
amount,
|
||||
EarningsAdjustmentReasonType.MANUAL,
|
||||
batch.getReasonDescription(),
|
||||
idempotencyKey,
|
||||
null);
|
||||
|
||||
if (!StringUtils.hasText(item.getAdjustmentId())) {
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(item.getId());
|
||||
patch.setAdjustmentId(adjustment.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
patch.setFailureReason(null);
|
||||
itemMapper.updateById(patch);
|
||||
}
|
||||
|
||||
adjustmentService.triggerApplyAsync(adjustment.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void retryOnce(String batchId) {
|
||||
EarningsDeductionBatchEntity batch = this.getById(batchId);
|
||||
if (batch == null) {
|
||||
return;
|
||||
}
|
||||
logOnce(batch, EarningsDeductionLogEventType.RETRY_STARTED, batch.getStatus() == null ? null : batch.getStatus().name(),
|
||||
EarningsDeductionStatus.PROCESSING.name(), "开始重试失败项", null);
|
||||
|
||||
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, batch.getTenantId())
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batch.getId())
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false));
|
||||
for (EarningsDeductionItemEntity item : items) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (item.getStatus() != EarningsDeductionStatus.FAILED) {
|
||||
continue;
|
||||
}
|
||||
if (!StringUtils.hasText(item.getAdjustmentId())) {
|
||||
continue;
|
||||
}
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(item.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
patch.setFailureReason(null);
|
||||
itemMapper.updateById(patch);
|
||||
adjustmentService.triggerApplyAsync(item.getAdjustmentId());
|
||||
}
|
||||
|
||||
EarningsDeductionBatchEntity patchBatch = new EarningsDeductionBatchEntity();
|
||||
patchBatch.setId(batch.getId());
|
||||
patchBatch.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
this.updateById(patchBatch);
|
||||
}
|
||||
|
||||
private void reconcile(EarningsDeductionBatchEntity batch) {
|
||||
if (batch == null || !StringUtils.hasText(batch.getId())) {
|
||||
return;
|
||||
}
|
||||
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, batch.getTenantId())
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batch.getId())
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false));
|
||||
if (items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
AtomicBoolean anyFailed = new AtomicBoolean(false);
|
||||
AtomicBoolean anyProcessing = new AtomicBoolean(false);
|
||||
for (EarningsDeductionItemEntity item : items) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (!StringUtils.hasText(item.getAdjustmentId())) {
|
||||
anyProcessing.set(true);
|
||||
continue;
|
||||
}
|
||||
EarningsLineAdjustmentEntity adjustment = adjustmentService.getById(item.getAdjustmentId());
|
||||
if (adjustment == null || adjustment.getStatus() == null) {
|
||||
anyProcessing.set(true);
|
||||
continue;
|
||||
}
|
||||
if (adjustment.getStatus() == com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus.APPLIED) {
|
||||
if (item.getStatus() != EarningsDeductionStatus.APPLIED) {
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(item.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.APPLIED);
|
||||
patch.setFailureReason(null);
|
||||
itemMapper.updateById(patch);
|
||||
}
|
||||
} else if (adjustment.getStatus() == com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus.FAILED) {
|
||||
anyFailed.set(true);
|
||||
if (item.getStatus() != EarningsDeductionStatus.FAILED) {
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(item.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.FAILED);
|
||||
patch.setFailureReason(adjustment.getFailureReason());
|
||||
itemMapper.updateById(patch);
|
||||
}
|
||||
logOnce(batch, EarningsDeductionLogEventType.ITEM_FAILED, EarningsDeductionStatus.PROCESSING.name(),
|
||||
EarningsDeductionStatus.FAILED.name(), "明细失败", JSON.toJSONString(Objects.requireNonNullElse(adjustment.getFailureReason(), "")));
|
||||
} else {
|
||||
anyProcessing.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (anyFailed.get()) {
|
||||
if (batch.getStatus() != EarningsDeductionStatus.FAILED) {
|
||||
EarningsDeductionBatchEntity patch = new EarningsDeductionBatchEntity();
|
||||
patch.setId(batch.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.FAILED);
|
||||
this.updateById(patch);
|
||||
}
|
||||
logOnce(batch, EarningsDeductionLogEventType.BATCH_FAILED, EarningsDeductionStatus.PROCESSING.name(),
|
||||
EarningsDeductionStatus.FAILED.name(), "批次失败", null);
|
||||
return;
|
||||
}
|
||||
if (anyProcessing.get()) {
|
||||
return;
|
||||
}
|
||||
if (batch.getStatus() != EarningsDeductionStatus.APPLIED) {
|
||||
EarningsDeductionBatchEntity patch = new EarningsDeductionBatchEntity();
|
||||
patch.setId(batch.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.APPLIED);
|
||||
this.updateById(patch);
|
||||
}
|
||||
logOnce(batch, EarningsDeductionLogEventType.BATCH_APPLIED, EarningsDeductionStatus.PROCESSING.name(),
|
||||
EarningsDeductionStatus.APPLIED.name(), "批次完成", null);
|
||||
}
|
||||
|
||||
private void ensureItems(EarningsDeductionBatchEntity batch, List<String> clerkIds) {
|
||||
if (batch == null || !StringUtils.hasText(batch.getId()) || CollectionUtils.isEmpty(clerkIds)) {
|
||||
return;
|
||||
}
|
||||
for (String clerkId : clerkIds) {
|
||||
if (!StringUtils.hasText(clerkId)) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal base = itemMapper.sumOrderPositiveBase(batch.getTenantId(), clerkId, batch.getWindowBeginTime(), batch.getWindowEndTime());
|
||||
BigDecimal baseAmount = base == null ? BigDecimal.ZERO : base;
|
||||
BigDecimal applyAmount = calculateApplyAmount(batch.getRuleType(), batch.getRuleValue(), batch.getOperation(), baseAmount);
|
||||
|
||||
EarningsDeductionItemEntity item = new EarningsDeductionItemEntity();
|
||||
item.setId(IdUtils.getUuid());
|
||||
item.setBatchId(batch.getId());
|
||||
item.setTenantId(batch.getTenantId());
|
||||
item.setClerkId(clerkId);
|
||||
item.setBaseAmount(baseAmount.setScale(2, RoundingMode.HALF_UP));
|
||||
item.setApplyAmount(applyAmount.setScale(2, RoundingMode.HALF_UP));
|
||||
item.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
item.setDeleted(false);
|
||||
try {
|
||||
itemMapper.insert(item);
|
||||
} catch (DuplicateKeyException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void markItemFailed(String itemId, String reason) {
|
||||
if (!StringUtils.hasText(itemId)) {
|
||||
return;
|
||||
}
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(itemId);
|
||||
patch.setStatus(EarningsDeductionStatus.FAILED);
|
||||
patch.setFailureReason(reason);
|
||||
itemMapper.updateById(patch);
|
||||
}
|
||||
|
||||
private BigDecimal calculateApplyAmount(EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
BigDecimal baseAmount) {
|
||||
BigDecimal resolvedBase = baseAmount == null ? BigDecimal.ZERO : baseAmount;
|
||||
BigDecimal resolvedRule = ruleValue == null ? BigDecimal.ZERO : ruleValue;
|
||||
BigDecimal amount;
|
||||
if (ruleType == EarningsDeductionRuleType.FIXED) {
|
||||
amount = resolvedRule;
|
||||
} else {
|
||||
amount = resolvedBase.multiply(resolvedRule).divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
|
||||
}
|
||||
amount = amount.setScale(2, RoundingMode.HALF_UP);
|
||||
if (operation == EarningsDeductionOperationType.PUNISHMENT) {
|
||||
return amount.abs().negate();
|
||||
}
|
||||
return amount.abs();
|
||||
}
|
||||
|
||||
private void validateCreateRequest(String tenantId,
|
||||
List<String> clerkIds,
|
||||
LocalDateTime beginTime,
|
||||
LocalDateTime endTime,
|
||||
EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
String reasonDescription,
|
||||
String idempotencyKey) {
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
throw new CustomException("tenant missing");
|
||||
}
|
||||
if (CollectionUtils.isEmpty(clerkIds)) {
|
||||
throw new CustomException("clerkIds required");
|
||||
}
|
||||
if (beginTime == null || endTime == null) {
|
||||
throw new CustomException("time range required");
|
||||
}
|
||||
if (beginTime.isAfter(endTime)) {
|
||||
throw new CustomException("beginTime must be <= endTime");
|
||||
}
|
||||
if (ruleType == null) {
|
||||
throw new CustomException("ruleType required");
|
||||
}
|
||||
if (ruleValue == null || ruleValue.compareTo(BigDecimal.ZERO) == 0) {
|
||||
throw new CustomException("ruleValue must be non-zero");
|
||||
}
|
||||
if (operation == null) {
|
||||
throw new CustomException("operation required");
|
||||
}
|
||||
if (!StringUtils.hasText(idempotencyKey)) {
|
||||
throw new CustomException("Idempotency-Key required");
|
||||
}
|
||||
if (!StringUtils.hasText(reasonDescription) || !StringUtils.hasText(reasonDescription.trim())) {
|
||||
throw new CustomException("reasonDescription required");
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> normalizeClerkIds(List<String> clerkIds) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (CollectionUtils.isEmpty(clerkIds)) {
|
||||
return result;
|
||||
}
|
||||
for (String id : clerkIds) {
|
||||
if (!StringUtils.hasText(id)) {
|
||||
continue;
|
||||
}
|
||||
String trimmed = id.trim();
|
||||
if (!trimmed.isEmpty() && !result.contains(trimmed)) {
|
||||
result.add(trimmed);
|
||||
}
|
||||
}
|
||||
result.sort(String::compareTo);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String buildPayload(EarningsDeductionBatchEntity batch, List<String> clerkIds) {
|
||||
if (batch == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> resolvedClerkIds = clerkIds == null ? List.of() : clerkIds;
|
||||
return JSON.toJSONString(new Object() {
|
||||
public final String batchId = batch.getId();
|
||||
public final String ruleType = batch.getRuleType() == null ? null : batch.getRuleType().name();
|
||||
public final String operation = batch.getOperation() == null ? null : batch.getOperation().name();
|
||||
public final BigDecimal ruleValue = batch.getRuleValue();
|
||||
public final LocalDateTime beginTime = batch.getWindowBeginTime();
|
||||
public final LocalDateTime endTime = batch.getWindowEndTime();
|
||||
public final String reasonDescription = batch.getReasonDescription();
|
||||
public final List<String> clerkIds = resolvedClerkIds;
|
||||
});
|
||||
}
|
||||
|
||||
private void logOnce(EarningsDeductionBatchEntity batch,
|
||||
EarningsDeductionLogEventType eventType,
|
||||
String from,
|
||||
String to,
|
||||
String message,
|
||||
String payload) {
|
||||
if (batch == null || eventType == null) {
|
||||
return;
|
||||
}
|
||||
Long count = logMapper.selectCount(Wrappers.lambdaQuery(EarningsDeductionBatchLogEntity.class)
|
||||
.eq(EarningsDeductionBatchLogEntity::getTenantId, batch.getTenantId())
|
||||
.eq(EarningsDeductionBatchLogEntity::getBatchId, batch.getId())
|
||||
.eq(EarningsDeductionBatchLogEntity::getEventType, eventType)
|
||||
.eq(EarningsDeductionBatchLogEntity::getDeleted, false));
|
||||
if (count != null && count > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
EarningsDeductionBatchLogEntity log = new EarningsDeductionBatchLogEntity();
|
||||
log.setId(IdUtils.getUuid());
|
||||
log.setTenantId(batch.getTenantId());
|
||||
log.setBatchId(batch.getId());
|
||||
log.setEventType(eventType);
|
||||
log.setStatusFrom(from);
|
||||
log.setStatusTo(to);
|
||||
log.setMessage(message);
|
||||
log.setPayload(payload);
|
||||
log.setDeleted(false);
|
||||
logMapper.insert(log);
|
||||
}
|
||||
|
||||
private String computeRequestHash(String tenantId,
|
||||
List<String> clerkIds,
|
||||
LocalDateTime beginTime,
|
||||
LocalDateTime endTime,
|
||||
EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
String reasonDescription) {
|
||||
String normalizedRule = (ruleValue == null ? BigDecimal.ZERO : ruleValue).setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
String raw = tenantId + "|" +
|
||||
String.join(",", clerkIds) + "|" +
|
||||
beginTime.toString() + "|" +
|
||||
endTime.toString() + "|" +
|
||||
(ruleType == null ? "" : ruleType.name()) + "|" +
|
||||
normalizedRule + "|" +
|
||||
(operation == null ? "" : operation.name()) + "|" +
|
||||
reasonDescription.trim();
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
return toHex(bytes);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("hash failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String toHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.starry.admin.modules.order.module.constant.OrderConstant;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
@@ -48,6 +49,8 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
|
||||
line.setTenantId(orderInfo.getTenantId());
|
||||
line.setClerkId(orderInfo.getAcceptBy());
|
||||
line.setOrderId(orderInfo.getId());
|
||||
line.setSourceType(EarningsSourceType.ORDER);
|
||||
line.setSourceId(orderInfo.getId());
|
||||
line.setAmount(amount);
|
||||
line.setEarningType(EarningsType.ORDER);
|
||||
line.setUnlockTime(unlockTime);
|
||||
@@ -85,6 +88,14 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
|
||||
public List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now) {
|
||||
// pick oldest unlocked first (status in available or frozen with unlock<=now)
|
||||
List<EarningsLineEntity> list = this.baseMapper.selectWithdrawableLines(clerkId, now);
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (EarningsLineEntity line : list) {
|
||||
BigDecimal value = line.getAmount() == null ? BigDecimal.ZERO : line.getAmount();
|
||||
total = total.add(value);
|
||||
}
|
||||
if (total.compareTo(amount) < 0) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
BigDecimal acc = BigDecimal.ZERO;
|
||||
List<EarningsLineEntity> picked = new ArrayList<>();
|
||||
for (EarningsLineEntity e : list) {
|
||||
@@ -140,6 +151,8 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
|
||||
line.setOrderId(orderId);
|
||||
line.setTenantId(tenantId);
|
||||
line.setClerkId(targetClerkId);
|
||||
line.setSourceType(EarningsSourceType.ORDER);
|
||||
line.setSourceId(orderId);
|
||||
line.setAmount(normalized.negate());
|
||||
line.setEarningType(EarningsType.ADJUSTMENT);
|
||||
line.setStatus(resolvedStatus);
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsLineStatus;
|
||||
import com.starry.admin.modules.withdraw.enums.WithdrawalRequestStatus;
|
||||
import com.starry.admin.modules.withdraw.mapper.WithdrawalRequestMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
@@ -43,6 +45,20 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
|
||||
WithdrawalRequestEntity active = this.lambdaQuery()
|
||||
.eq(WithdrawalRequestEntity::getTenantId, tenantId)
|
||||
.eq(WithdrawalRequestEntity::getClerkId, clerkId)
|
||||
.in(WithdrawalRequestEntity::getStatus,
|
||||
WithdrawalRequestStatus.PENDING.getCode(),
|
||||
WithdrawalRequestStatus.PROCESSING.getCode())
|
||||
.orderByDesc(WithdrawalRequestEntity::getCreatedTime)
|
||||
.last("limit 1")
|
||||
.one();
|
||||
if (active != null) {
|
||||
throw new CustomException("仅可同时存在一笔提现申请,请等待当前申请处理完成后再提交");
|
||||
}
|
||||
|
||||
ClerkPayeeProfileEntity payeeProfile = clerkPayeeProfileService.getByClerk(tenantId, clerkId);
|
||||
if (payeeProfile == null || !StringUtils.hasText(payeeProfile.getQrCodeUrl())) {
|
||||
throw new CustomException("请先上传支付宝收款码");
|
||||
@@ -66,7 +82,7 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
boolean updated = earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getId, line.getId())
|
||||
.eq(EarningsLineEntity::getStatus, line.getStatus()) // Verify status unchanged
|
||||
.set(EarningsLineEntity::getStatus, "withdrawing")
|
||||
.set(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||
.set(EarningsLineEntity::getWithdrawalId, tempWithdrawalId));
|
||||
if (!updated) {
|
||||
// Another request already took this line
|
||||
@@ -88,7 +104,7 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
req.setNetAmount(amount);
|
||||
req.setDestAccount(payeeProfile.getDisplayName());
|
||||
req.setPayeeSnapshot(snapshotJson);
|
||||
req.setStatus("pending");
|
||||
req.setStatus(WithdrawalRequestStatus.PENDING.getCode());
|
||||
req.setOutBizNo(req.getId());
|
||||
this.save(req);
|
||||
|
||||
@@ -120,22 +136,23 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
public void markManualSuccess(String requestId, String operatorBy) {
|
||||
WithdrawalRequestEntity req = this.getById(requestId);
|
||||
if (req == null) throw new CustomException("请求不存在");
|
||||
if (!"pending".equals(req.getStatus()) && !"processing".equals(req.getStatus())) {
|
||||
if (!WithdrawalRequestStatus.PENDING.getCode().equals(req.getStatus())
|
||||
&& !WithdrawalRequestStatus.PROCESSING.getCode().equals(req.getStatus())) {
|
||||
throw new CustomException("当前状态不可操作");
|
||||
}
|
||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||
update.setId(req.getId());
|
||||
update.setStatus("success");
|
||||
update.setStatus(WithdrawalRequestStatus.SUCCESS.getCode());
|
||||
this.updateById(update);
|
||||
|
||||
// Set reserved earnings lines to withdrawn
|
||||
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getWithdrawalId, req.getId())
|
||||
.eq(EarningsLineEntity::getStatus, "withdrawing")
|
||||
.set(EarningsLineEntity::getStatus, "withdrawn"));
|
||||
.eq(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||
.set(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWN.getCode()));
|
||||
|
||||
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
||||
"PAYOUT_SUCCESS", req.getStatus(), "success",
|
||||
"PAYOUT_SUCCESS", req.getStatus(), WithdrawalRequestStatus.SUCCESS.getCode(),
|
||||
"手动打款成功,操作人=" + operatorBy, null);
|
||||
}
|
||||
|
||||
@@ -144,22 +161,62 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
public void autoPayout(String requestId) {
|
||||
WithdrawalRequestEntity req = this.getById(requestId);
|
||||
if (req == null) throw new CustomException("请求不存在");
|
||||
if (!"pending".equals(req.getStatus())) {
|
||||
if (!WithdrawalRequestStatus.PENDING.getCode().equals(req.getStatus())) {
|
||||
throw new CustomException("当前状态不可自动打款");
|
||||
}
|
||||
// Transition to processing and log
|
||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||
update.setId(req.getId());
|
||||
update.setStatus("processing");
|
||||
update.setStatus(WithdrawalRequestStatus.PROCESSING.getCode());
|
||||
this.updateById(update);
|
||||
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
||||
"PAYOUT_REQUESTED", req.getStatus(), "processing",
|
||||
"PAYOUT_REQUESTED", req.getStatus(), WithdrawalRequestStatus.PROCESSING.getCode(),
|
||||
"发起支付宝打款(未实现)", null);
|
||||
|
||||
// Not implemented yet
|
||||
throw new UnsupportedOperationException("Alipay payout not implemented");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void reject(String requestId, String reason) {
|
||||
WithdrawalRequestEntity req = this.getById(requestId);
|
||||
if (req == null) throw new CustomException("请求不存在");
|
||||
if (WithdrawalRequestStatus.SUCCESS.getCode().equals(req.getStatus())) {
|
||||
throw new CustomException("已成功的提现不可拒绝");
|
||||
}
|
||||
if (WithdrawalRequestStatus.CANCELED.getCode().equals(req.getStatus())
|
||||
|| WithdrawalRequestStatus.REJECTED.getCode().equals(req.getStatus())) {
|
||||
return;
|
||||
}
|
||||
|
||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||
update.setId(req.getId());
|
||||
update.setStatus(WithdrawalRequestStatus.CANCELED.getCode());
|
||||
update.setFailureReason(StringUtils.hasText(reason) ? reason : "rejected");
|
||||
this.updateById(update);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
|
||||
.eq(EarningsLineEntity::getWithdrawalId, req.getId())
|
||||
.eq(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||
.list();
|
||||
for (EarningsLineEntity line : lines) {
|
||||
LocalDateTime unlock = line.getUnlockTime();
|
||||
String restored = unlock != null && unlock.isAfter(now)
|
||||
? EarningsLineStatus.FROZEN.getCode()
|
||||
: EarningsLineStatus.AVAILABLE.getCode();
|
||||
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getId, line.getId())
|
||||
.set(EarningsLineEntity::getWithdrawalId, null)
|
||||
.set(EarningsLineEntity::getStatus, restored));
|
||||
}
|
||||
|
||||
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
||||
"PAYOUT_REJECTED", req.getStatus(), update.getStatus(),
|
||||
"拒绝提现,原因=" + (reason == null ? "" : reason), null);
|
||||
}
|
||||
|
||||
private String buildPayeeSnapshot(ClerkPayeeProfileEntity profile, LocalDateTime confirmedAt) {
|
||||
PayeeSnapshotVo snapshot = new PayeeSnapshotVo();
|
||||
snapshot.setChannel(profile.getChannel());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.starry.admin.modules.withdraw.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -15,6 +16,13 @@ public class ClerkEarningLineVo {
|
||||
private EarningsType earningType;
|
||||
private String withdrawalId;
|
||||
|
||||
private EarningsAdjustmentReasonType adjustmentReasonType;
|
||||
private String adjustmentReasonDescription;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime adjustmentEffectiveTime;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime unlockTime;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user