Compare commits

..

10 Commits

Author SHA1 Message Date
irving
9b9b1024c8 fix(deduction): make apply idempotent
Some checks failed
Build and Push Backend / docker (push) Has been cancelled
2026-01-19 00:19:02 -05:00
irving
fffc623ab0 fix(withdraw): tenant filter + enums + tests 2026-01-18 23:58:27 -05:00
irving
6a3b4fef1f test(apitest): add e2e seed endpoints and coverage 2026-01-17 00:49:54 -05:00
irving
e2300fc7d0 feat: earnings deduction batch + test auth hardening 2026-01-16 13:30:04 -05:00
irving
985b35cd90 test: add wechat integration test suite
Some checks failed
Build and Push Backend / docker (push) Has been cancelled
- Add llm/wechat-subsystem-test-matrix.md and tests covering Wx controllers/services\n- Make ApiTestDataSeeder personnel group seeding idempotent for full-suite stability
2026-01-12 18:54:14 -05:00
irving
56239450d4 Add earnings adjustments, withdrawal reject, and auth guard 2026-01-12 12:46:42 -05:00
irving
d335c577d3 fix: restore apitest commodities
Some checks failed
Build and Push Backend / docker (push) Has been cancelled
2026-01-02 02:03:20 -05:00
irving
6fbc28d6f2 fix: harden apitest seeder and pk schedulers 2026-01-02 01:57:41 -05:00
irving
17a8c358a8 feat(pk): implement PK (Player-Killer) system with lifecycle management
- Add PK entity fields: winner, scores, and setting_id
- Implement force start/end API endpoints for clerk PK
- Add PK lifecycle service with auto-start/end scheduling
- Add Redis-based PK state management
- Implement PK detail service with live/history/upcoming queries
- Add WeChat PK controller with history and live PK endpoints
- Add comprehensive PK integration tests
- Create PK setting management with tenant-specific configs
- Add database migrations for PK scores, winner, settings, and menu
- Add PK-related DTOs and enums (status, menu paths)
- Add TenantScope utility for tenant context management
2026-01-02 01:34:03 -05:00
irving
a7e567e9b4 working but not tested 2026-01-01 00:41:55 -05:00
165 changed files with 20397 additions and 101 deletions

2
justfile Normal file
View File

@@ -0,0 +1,2 @@
iperf:
iperf3 -c 101.43.124.74

View 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 entitys `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`)

View 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)

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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("*");
}
}

View File

@@ -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);
}
/**

View File

@@ -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));
}
}

View File

@@ -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
*/

View File

@@ -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 &gt;= #{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);
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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 &gt; #{minAmount} "
+ " AND o.accept_by IN (#{clerkAId}, #{clerkBId}) "
+ " AND o.order_end_time &gt;= #{startTime} "
+ " AND o.order_end_time &lt;= #{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);
}

View File

@@ -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

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,9 @@
package com.starry.admin.modules.pk.dto;
/**
* PK WebSocket 事件类型。
*/
public enum PkEventType {
SCORE_UPDATE,
STATE_CHANGE
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,10 @@
package com.starry.admin.modules.pk.event;
/**
* PK 贡献来源类型。
*/
public enum PkContributionSource {
ORDER,
GIFT,
RECHARGE
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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() {
}
}

View File

@@ -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);
}
}
}

View File

@@ -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() {
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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() {
}
}

View File

@@ -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() {
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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> {
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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());
}
}
}

View File

@@ -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()) {

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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> {}

View File

@@ -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> {}

View File

@@ -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 &gt; 0 " +
" AND el.status &lt;&gt; 'reversed' " +
" AND oi.order_end_time IS NOT NULL " +
" AND oi.order_end_time &gt;= #{begin} " +
" AND oi.order_end_time &lt;= #{end} " +
"</script>")
BigDecimal sumOrderPositiveBase(@Param("tenantId") String tenantId,
@Param("clerkId") String clerkId,
@Param("begin") LocalDateTime begin,
@Param("end") LocalDateTime end);
}

View File

@@ -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> {}

View File

@@ -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 &gt;= #{startTime}" +
"</if>" +
"<if test='endTime != null'>" +
" AND unlock_time &lt;= #{endTime}" +
"</if>" +
"</script>")
BigDecimal sumAdjustmentsByClerk(@Param("tenantId") String tenantId,
@Param("clerkId") String clerkId,
@Param("startTime") String startTime,
@Param("endTime") String endTime);
}

View File

@@ -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());
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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());

View File

@@ -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