From 207115e18f0a892036c2ad66780defc9ff7d54b6 Mon Sep 17 00:00:00 2001 From: malei <> Date: Sun, 2 Aug 2026 01:02:10 +0800 Subject: [PATCH] feat: add secure store onboarding and staff invites --- README.md | 15 +- .../20260802_create_store_onboarding.sql | 89 +++++ db/migrations/README.md | 2 + deploy/nginx-petstore.conf.example | 9 + .../com/petstore/auth/AuthInterceptor.java | 21 +- .../ProductionDatabasePreflightRunner.java | 28 ++ .../controller/AdminOnboardingController.java | 54 +++ .../controller/AdminStoreController.java | 5 +- .../MerchantOnboardingController.java | 71 ++++ .../controller/StaffInvitationController.java | 138 +++++++ .../petstore/controller/StoreController.java | 41 ++- .../petstore/controller/UserController.java | 39 +- .../java/com/petstore/entity/AuditLog.java | 68 ++++ .../com/petstore/entity/StaffInvitation.java | 80 +++++ src/main/java/com/petstore/entity/Store.java | 10 + .../com/petstore/mapper/AuditLogMapper.java | 7 + .../mapper/StaffInvitationMapper.java | 31 ++ .../com/petstore/service/AuditLogService.java | 101 ++++++ .../service/BusinessEventService.java | 19 + .../com/petstore/service/FlowException.java | 21 ++ .../service/MerchantOnboardingService.java | 126 +++++++ .../service/StaffInvitationService.java | 337 ++++++++++++++++++ .../service/StoreOnboardingService.java | 126 +++++++ .../com/petstore/service/StoreService.java | 17 + .../com/petstore/service/UserService.java | 88 ----- .../auth/AuthInterceptorActiveUserTest.java | 106 ++++++ .../controller/AdminStoreControllerTest.java | 6 +- .../controller/StoreControllerTest.java | 21 +- .../mapper/StoreOnboardingMigrationTest.java | 24 ++ .../petstore/service/AuditLogServiceTest.java | 58 +++ .../MerchantOnboardingServiceTest.java | 96 +++++ .../service/StaffInvitationServiceTest.java | 170 +++++++++ .../service/StoreOnboardingServiceTest.java | 116 ++++++ .../petstore/service/StoreServiceTest.java | 78 ++++ 34 files changed, 2078 insertions(+), 140 deletions(-) create mode 100644 db/migrations/20260802_create_store_onboarding.sql create mode 100644 src/main/java/com/petstore/controller/AdminOnboardingController.java create mode 100644 src/main/java/com/petstore/controller/MerchantOnboardingController.java create mode 100644 src/main/java/com/petstore/controller/StaffInvitationController.java create mode 100644 src/main/java/com/petstore/entity/AuditLog.java create mode 100644 src/main/java/com/petstore/entity/StaffInvitation.java create mode 100644 src/main/java/com/petstore/mapper/AuditLogMapper.java create mode 100644 src/main/java/com/petstore/mapper/StaffInvitationMapper.java create mode 100644 src/main/java/com/petstore/service/AuditLogService.java create mode 100644 src/main/java/com/petstore/service/FlowException.java create mode 100644 src/main/java/com/petstore/service/MerchantOnboardingService.java create mode 100644 src/main/java/com/petstore/service/StaffInvitationService.java create mode 100644 src/main/java/com/petstore/service/StoreOnboardingService.java create mode 100644 src/test/java/com/petstore/auth/AuthInterceptorActiveUserTest.java create mode 100644 src/test/java/com/petstore/mapper/StoreOnboardingMigrationTest.java create mode 100644 src/test/java/com/petstore/service/AuditLogServiceTest.java create mode 100644 src/test/java/com/petstore/service/MerchantOnboardingServiceTest.java create mode 100644 src/test/java/com/petstore/service/StaffInvitationServiceTest.java create mode 100644 src/test/java/com/petstore/service/StoreOnboardingServiceTest.java create mode 100644 src/test/java/com/petstore/service/StoreServiceTest.java diff --git a/README.md b/README.md index 742f7d4..d3ac1ff 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,12 @@ curl http://localhost:8080/api/store/list ``` 脚本把历史报告统一标记为 `unknown`,迁移后的新报告默认 `unsent`;不得推测历史发送事实。末尾三项状态与回执一致性验证必须全部为 0。 +9. **门店开通、员工邀请与操作审计**:报告发送状态验证完成后执行: + ```bash + mysql -h -u -p petstore < db/migrations/20260802_create_store_onboarding.sql + ``` + 脚本把历史门店开通状态标为不可推断的 `unknown`,建立只保存 token SHA-256 的一次性员工邀请和不可变低敏审计表。末尾六项状态/回执一致性验证必须全部为 0;不得恢复 legacy 永久邀请码注册。 + ### production profile ```bash @@ -122,14 +128,14 @@ production profile 下: ### 生产只读预检 -完成备份和五个迁移后,以最终生产环境变量运行: +完成备份和六个迁移后,以最终生产环境变量运行: ```bash chmod +x deploy/release-preflight.sh deploy/production-smoke.sh BUILD_FIRST=1 deploy/release-preflight.sh ``` -脚本先校验生产配置、上传目录、Java 与 FFmpeg,再启动无 Web 的 `production` profile,执行 JPA schema `validate` 和 18 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。 +脚本先校验生产配置、上传目录、Java 与 FFmpeg,再启动无 Web 的 `production` profile,执行 JPA schema `validate` 和 24 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。 ## 测试 @@ -186,6 +192,7 @@ API_ORIGIN=https:// backend/deploy/production-smoke.sh - [ ] 已执行 `20260801_create_business_event.sql`,四项事件回填验证计数均为 0 - [ ] 已执行 `20260801_create_booking_capacity.sql`,容量与时长验证计数均为 0 - [ ] 已执行 `20260801_create_report_send_status.sql`,历史 `unknown` 与三项回执一致性验证均为 0 +- [ ] 已执行 `20260802_create_store_onboarding.sql`,历史开通状态与六项邀请/回执验证均为 0 - [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web 源 - [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出 - [ ] readiness 与上线后只读冒烟全部通过 @@ -193,12 +200,14 @@ API_ORIGIN=https:// backend/deploy/production-smoke.sh ## 安全说明 -- **最小鉴权上下文**:HMAC session token(`SessionTokenService`)+ `AuthInterceptor`,不引入完整 Spring Security。 +- **最小鉴权上下文**:HMAC session token(`SessionTokenService`)+ `AuthInterceptor`,并在每次受保护请求核验活跃账号的 role/storeId 未变化;删除员工会立即使旧会话失效。 - **公开接口白名单**:见 `AuthInterceptor.PUBLIC_PATTERNS`;其余 `/api/**` 要求 `Authorization: Bearer `。 - **跨店/跨用户边界**:所有 protected 接口从 `CurrentUserContext` 派生 `userId/storeId/role`,不信任请求体里的身份字段。 - **公开报告页**:`GET /api/report/get?token=` 不返回 top-level `userId/storeId/reportToken`;日志只记 token SHA-256 前 8 位 hash。 - **回访池脱敏**:`/api/report/leads` 不返回完整 `wechatOpenid/wechatUnionid`,只返回 `wechatBound` + 脱敏 id。 - **上传路径保护**:`FileController` 归一化路径 + 扩展名/MIME 校验,拒绝 `..` 逃逸。 +- **员工邀请**:原始 256-bit token 只在创建响应返回一次,数据库只存 SHA-256;邀请绑定微信核验手机号、限时、一次使用、可撤销。 +- **操作审计**:门店开通/设置和员工权限操作写入不可变低敏 AuditLog,metadata 禁止 PII、凭证和原始请求体。 ## 参与贡献 diff --git a/db/migrations/20260802_create_store_onboarding.sql b/db/migrations/20260802_create_store_onboarding.sql new file mode 100644 index 0000000..92c78de --- /dev/null +++ b/db/migrations/20260802_create_store_onboarding.sql @@ -0,0 +1,89 @@ +-- Petstore Phase 0:门店开通状态、一次性员工邀请与不可变操作审计 +-- 前置:20260801_create_report_send_status.sql 已执行。 +-- 语义:历史门店开通状态不可推断,统一为 unknown;新门店由应用写 in_progress。 + +ALTER TABLE t_store + ADD COLUMN onboarding_status VARCHAR(16) NULL COMMENT 'unknown | in_progress | completed'; + +ALTER TABLE t_store + ADD COLUMN onboarding_completed_at DATETIME NULL COMMENT '老板显式完成开通时间'; + +ALTER TABLE t_store + ADD COLUMN onboarding_completed_by_user_id BIGINT NULL COMMENT '显式完成开通的老板用户'; + +UPDATE t_store +SET onboarding_status = 'unknown'; + +ALTER TABLE t_store + MODIFY COLUMN onboarding_status VARCHAR(16) NOT NULL DEFAULT 'in_progress' + COMMENT 'unknown | in_progress | completed'; + +CREATE TABLE t_staff_invitation ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + invitation_id VARCHAR(36) NOT NULL, + token_hash VARCHAR(64) NOT NULL COMMENT '原始 token 的 SHA-256;原始 token 不落库', + store_id BIGINT NOT NULL, + invited_name VARCHAR(64) NOT NULL, + invited_phone VARCHAR(20) NOT NULL COMMENT '仅服务端用于微信核验匹配;客户端只返回脱敏值', + status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending | accepted | revoked | expired', + expires_at DATETIME NOT NULL, + created_by_user_id BIGINT NOT NULL, + accepted_by_user_id BIGINT NULL, + accepted_at DATETIME NULL, + revoked_by_user_id BIGINT NULL, + revoked_at DATETIME NULL, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + CONSTRAINT uk_staff_invitation_public_id UNIQUE (invitation_id), + CONSTRAINT uk_staff_invitation_token_hash UNIQUE (token_hash), + INDEX idx_staff_invite_store_status (store_id, status, expires_at), + INDEX idx_staff_invite_phone (store_id, invited_phone, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='限时、一次性员工邀请'; + +CREATE TABLE t_audit_log ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + audit_id VARCHAR(36) NOT NULL, + store_id BIGINT NOT NULL, + actor_user_id BIGINT NULL, + actor_role VARCHAR(16) NULL, + action VARCHAR(64) NOT NULL, + target_type VARCHAR(32) NOT NULL, + target_id VARCHAR(64) NULL, + outcome VARCHAR(16) NOT NULL, + metadata_json TEXT NOT NULL COMMENT '服务端白名单低敏标量 JSON', + occurred_at DATETIME NOT NULL, + create_time DATETIME NOT NULL, + CONSTRAINT uk_audit_log_audit_id UNIQUE (audit_id), + INDEX idx_audit_store_time (store_id, occurred_at), + INDEX idx_audit_action_time (action, occurred_at), + INDEX idx_audit_target (target_type, target_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='不可变操作审计'; + +-- 验证:以下六项必须全部为 0。 +SELECT COUNT(*) AS invalid_store_onboarding_status_count +FROM t_store +WHERE onboarding_status NOT IN ('unknown', 'in_progress', 'completed'); + +SELECT COUNT(*) AS completed_onboarding_without_receipt_count +FROM t_store +WHERE onboarding_status = 'completed' + AND (onboarding_completed_at IS NULL OR onboarding_completed_by_user_id IS NULL); + +SELECT COUNT(*) AS incomplete_onboarding_with_receipt_count +FROM t_store +WHERE onboarding_status IN ('unknown', 'in_progress') + AND (onboarding_completed_at IS NOT NULL OR onboarding_completed_by_user_id IS NOT NULL); + +SELECT COUNT(*) AS invalid_staff_invitation_status_count +FROM t_staff_invitation +WHERE status NOT IN ('pending', 'accepted', 'revoked', 'expired'); + +SELECT COUNT(*) AS inconsistent_staff_invitation_acceptance_count +FROM t_staff_invitation +WHERE (status = 'accepted' AND (accepted_by_user_id IS NULL OR accepted_at IS NULL)) + OR (status <> 'accepted' AND (accepted_by_user_id IS NOT NULL OR accepted_at IS NOT NULL)); + +SELECT COUNT(*) AS inconsistent_staff_invitation_revocation_count +FROM t_staff_invitation +WHERE (status = 'revoked' AND (revoked_by_user_id IS NULL OR revoked_at IS NULL)) + OR (status <> 'revoked' AND (revoked_by_user_id IS NOT NULL OR revoked_at IS NOT NULL)); diff --git a/db/migrations/README.md b/db/migrations/README.md index 0485ad8..69b03a6 100644 --- a/db/migrations/README.md +++ b/db/migrations/README.md @@ -18,4 +18,6 @@ `20260801_create_report_send_status.sql` 必须在预约容量迁移之后执行:新增报告显式确认发送状态与首次确认回执。历史报告统一标记为 `unknown`,不得推测为未发送或已发送;迁移后的新报告默认 `unsent`。脚本末尾三个验证计数必须为 0。 +`20260802_create_store_onboarding.sql` 必须在报告发送状态迁移之后执行:为历史门店写入不可推断的 `unknown` 开通状态,建立只存 token 摘要的限时员工邀请与低敏不可变操作审计。脚本末尾六个验证计数必须为 0;不得用 legacy `invite_code` 恢复公开员工注册。 + 正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。 diff --git a/deploy/nginx-petstore.conf.example b/deploy/nginx-petstore.conf.example index ee18bba..1909638 100644 --- a/deploy/nginx-petstore.conf.example +++ b/deploy/nginx-petstore.conf.example @@ -1,6 +1,12 @@ # Dedicated Petstore server blocks. Replace domains/certificate paths before use. # Keep these blocks separate from Gitea/GitLab configurations on a shared host. +# Deliberately log $uri instead of $request_uri/$request so report and invitation +# tokens carried in query strings never enter Nginx access logs. +log_format petstore_safe '$remote_addr - $remote_user [$time_local] ' + '"$request_method $uri $server_protocol" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent"'; + upstream petstore_backend_8080 { server 127.0.0.1:8080; keepalive 16; @@ -13,6 +19,7 @@ server { ssl_certificate_key /etc/letsencrypt/live/api.petstore.invalid/privkey.pem; client_max_body_size 200m; + access_log /var/log/nginx/petstore-api.access.log petstore_safe; location / { proxy_pass http://petstore_backend_8080; @@ -33,6 +40,7 @@ server { index index.html; ssl_certificate /etc/letsencrypt/live/admin.petstore.invalid/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/admin.petstore.invalid/privkey.pem; + access_log /var/log/nginx/petstore-admin.access.log petstore_safe; location /api/ { proxy_pass http://petstore_backend_8080; @@ -55,6 +63,7 @@ server { index index.html; ssl_certificate /etc/letsencrypt/live/report.petstore.invalid/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/report.petstore.invalid/privkey.pem; + access_log /var/log/nginx/petstore-report.access.log petstore_safe; location / { try_files $uri $uri/ /index.html; diff --git a/src/main/java/com/petstore/auth/AuthInterceptor.java b/src/main/java/com/petstore/auth/AuthInterceptor.java index 536e77e..f9c2dc2 100644 --- a/src/main/java/com/petstore/auth/AuthInterceptor.java +++ b/src/main/java/com/petstore/auth/AuthInterceptor.java @@ -1,5 +1,8 @@ package com.petstore.auth; +import com.petstore.entity.User; +import com.petstore.mapper.StoreMapper; +import com.petstore.mapper.UserMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -28,6 +31,8 @@ import java.util.Optional; public class AuthInterceptor implements HandlerInterceptor { private final SessionTokenService sessionTokenService; + private final UserMapper userMapper; + private final StoreMapper storeMapper; private final AntPathMatcher pathMatcher = new AntPathMatcher(); /** @@ -42,6 +47,9 @@ public class AuthInterceptor implements HandlerInterceptor { "POST /api/user/wx-phone-login", "POST /api/user/register-boss", "POST /api/user/register-staff", + "POST /api/onboarding/register-boss", + "GET /api/staff-invitations/preview", + "POST /api/staff-invitations/accept", "POST /api/sms/send", "GET /api/store/list", "GET /api/store/get", @@ -78,7 +86,18 @@ public class AuthInterceptor implements HandlerInterceptor { if (opt.isEmpty()) { return reject(response, "登录已失效,请重新登录"); } - CurrentUserContext.set(opt.get()); + CurrentUser current = opt.get(); + User activeUser = userMapper.findByIdAndDeletedFalse(current.userId()).orElse(null); + if (activeUser == null + || !java.util.Objects.equals(activeUser.getStoreId(), current.storeId()) + || !java.util.Objects.equals(activeUser.getRole(), current.role())) { + return reject(response, "账号权限已变更,请重新登录"); + } + if (current.isStoreUser() + && (current.storeId() == null || storeMapper.findByIdAndDeletedFalse(current.storeId()).isEmpty())) { + return reject(response, "门店已停用,请联系管理员"); + } + CurrentUserContext.set(current); return true; } diff --git a/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java b/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java index ebbe2eb..fcaf3ee 100644 --- a/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java +++ b/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java @@ -117,6 +117,34 @@ public class ProductionDatabasePreflightRunner implements ApplicationRunner { SELECT COUNT(*) FROM t_report WHERE send_status IN ('unknown', 'unsent') AND (sent_at IS NOT NULL OR sent_by_user_id IS NOT NULL OR send_channel IS NOT NULL) + """), + new Invariant("invalid_store_onboarding_status", """ + SELECT COUNT(*) FROM t_store + WHERE onboarding_status NOT IN ('unknown', 'in_progress', 'completed') + """), + new Invariant("completed_onboarding_without_receipt", """ + SELECT COUNT(*) FROM t_store + WHERE onboarding_status = 'completed' + AND (onboarding_completed_at IS NULL OR onboarding_completed_by_user_id IS NULL) + """), + new Invariant("incomplete_onboarding_with_receipt", """ + SELECT COUNT(*) FROM t_store + WHERE onboarding_status IN ('unknown', 'in_progress') + AND (onboarding_completed_at IS NOT NULL OR onboarding_completed_by_user_id IS NOT NULL) + """), + new Invariant("invalid_staff_invitation_status", """ + SELECT COUNT(*) FROM t_staff_invitation + WHERE status NOT IN ('pending', 'accepted', 'revoked', 'expired') + """), + new Invariant("inconsistent_staff_invitation_acceptance", """ + SELECT COUNT(*) FROM t_staff_invitation + WHERE (status = 'accepted' AND (accepted_by_user_id IS NULL OR accepted_at IS NULL)) + OR (status <> 'accepted' AND (accepted_by_user_id IS NOT NULL OR accepted_at IS NOT NULL)) + """), + new Invariant("inconsistent_staff_invitation_revocation", """ + SELECT COUNT(*) FROM t_staff_invitation + WHERE (status = 'revoked' AND (revoked_by_user_id IS NULL OR revoked_at IS NULL)) + OR (status <> 'revoked' AND (revoked_by_user_id IS NOT NULL OR revoked_at IS NOT NULL)) """) ); diff --git a/src/main/java/com/petstore/controller/AdminOnboardingController.java b/src/main/java/com/petstore/controller/AdminOnboardingController.java new file mode 100644 index 0000000..ec7eca2 --- /dev/null +++ b/src/main/java/com/petstore/controller/AdminOnboardingController.java @@ -0,0 +1,54 @@ +package com.petstore.controller; + +import com.petstore.auth.CurrentUser; +import com.petstore.auth.CurrentUserContext; +import com.petstore.service.FlowException; +import com.petstore.service.StoreOnboardingService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** 门店开通清单:门店成员可查看,只有老板可显式完成。 */ +@RestController +@RequestMapping("/api/admin/onboarding") +@RequiredArgsConstructor +@CrossOrigin +public class AdminOnboardingController { + + private final StoreOnboardingService onboardingService; + + @GetMapping("") + public Map status() { + CurrentUser user = CurrentUserContext.require(); + if (!user.isStoreUser() || user.storeId() == null) { + return Map.of("code", 403, "message", "仅门店成员可查看开通清单", "bizCode", "FORBIDDEN"); + } + try { + return Map.of("code", 200, "data", onboardingService.getStatus(user.storeId())); + } catch (FlowException e) { + return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode()); + } + } + + @PostMapping("/complete") + public Map complete() { + CurrentUser user = CurrentUserContext.require(); + if (!user.isBoss() || user.storeId() == null) { + return Map.of("code", 403, "message", "仅老板可完成门店开通", "bizCode", "FORBIDDEN"); + } + try { + return Map.of( + "code", 200, + "message", "门店已完成开通,可进入真实试点", + "data", onboardingService.complete(user.storeId(), user.userId(), user.role()) + ); + } catch (FlowException e) { + return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode()); + } + } +} diff --git a/src/main/java/com/petstore/controller/AdminStoreController.java b/src/main/java/com/petstore/controller/AdminStoreController.java index b775036..c5f1d27 100644 --- a/src/main/java/com/petstore/controller/AdminStoreController.java +++ b/src/main/java/com/petstore/controller/AdminStoreController.java @@ -14,7 +14,7 @@ import java.util.LinkedHashMap; import java.util.Map; /** - * 门店 Web 后台:本店资料(含 inviteCode 等内部字段)。 + * 门店 Web 后台:本店资料。legacy inviteCode 不再返回客户端。 * 规则:{@code rule:BR-ADMIN-001}、{@code rule:BR-ADMIN-002}、{@code rule:BR-ADMIN-003} */ @RestController @@ -44,7 +44,8 @@ public class AdminStoreController { data.put("longitude", store.getLongitude()); data.put("logo", store.getLogo()); data.put("intro", store.getIntro()); - data.put("inviteCode", store.getInviteCode()); + data.put("onboardingStatus", store.getOnboardingStatus()); + data.put("onboardingCompletedAt", store.getOnboardingCompletedAt()); data.put("bookingDayStart", store.getBookingDayStart() == null ? null : store.getBookingDayStart().toString()); data.put("bookingLastSlotStart", store.getBookingLastSlotStart() == null ? null : store.getBookingLastSlotStart().toString()); diff --git a/src/main/java/com/petstore/controller/MerchantOnboardingController.java b/src/main/java/com/petstore/controller/MerchantOnboardingController.java new file mode 100644 index 0000000..7cbf8eb --- /dev/null +++ b/src/main/java/com/petstore/controller/MerchantOnboardingController.java @@ -0,0 +1,71 @@ +package com.petstore.controller; + +import com.petstore.service.FlowException; +import com.petstore.service.MerchantOnboardingService; +import com.petstore.service.WechatMiniProgramService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** 新门店通过微信核验手机号后自助开通。 */ +@Slf4j +@RestController +@RequestMapping("/api/onboarding") +@RequiredArgsConstructor +@CrossOrigin +public class MerchantOnboardingController { + + private final MerchantOnboardingService onboardingService; + private final WechatMiniProgramService wechatMiniProgramService; + + @PostMapping("/register-boss") + public Map registerBoss(@RequestBody(required = false) Map params) { + if (params == null) { + return error(400, "INVALID_ONBOARDING_INPUT", "请求体不能为空"); + } + String phoneCode = params.get("phoneCode"); + if (phoneCode == null || phoneCode.isBlank()) { + return error(400, "WECHAT_PHONE_REQUIRED", "请先授权微信手机号"); + } + var phoneResult = wechatMiniProgramService.exchangePhoneCode(phoneCode); + if (!phoneResult.isOk()) { + return error(400, "WECHAT_PHONE_FAILED", phoneResult.errorMessage()); + } + + String openid = null; + String unionid = null; + String loginCode = params.get("loginCode"); + if (loginCode != null && !loginCode.isBlank()) { + var identity = wechatMiniProgramService.exchangeJsCode(loginCode); + if (identity.isOk()) { + openid = identity.openid(); + unionid = identity.unionid(); + } else { + log.debug("老板注册时 openid 沉淀跳过: {}", identity.errorMessage()); + } + } + try { + Map data = onboardingService.registerVerifiedBoss( + params.get("storeName"), params.get("bossName"), phoneResult.phone(), openid, unionid + ); + return Map.of("code", 200, "message", "门店创建成功,请继续完成开通清单", "data", data); + } catch (FlowException e) { + return error(e.code(), e.bizCode(), e.getMessage()); + } + } + + private static Map error(int code, String bizCode, String message) { + Map result = new LinkedHashMap<>(); + result.put("code", code); + result.put("bizCode", bizCode); + result.put("message", message == null || message.isBlank() ? "操作失败" : message); + return result; + } +} diff --git a/src/main/java/com/petstore/controller/StaffInvitationController.java b/src/main/java/com/petstore/controller/StaffInvitationController.java new file mode 100644 index 0000000..d051f92 --- /dev/null +++ b/src/main/java/com/petstore/controller/StaffInvitationController.java @@ -0,0 +1,138 @@ +package com.petstore.controller; + +import com.petstore.auth.CurrentUser; +import com.petstore.auth.CurrentUserContext; +import com.petstore.service.FlowException; +import com.petstore.service.StaffInvitationService; +import com.petstore.service.WechatMiniProgramService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** 老板签发/撤销邀请;员工在小程序用微信核验手机号后接受。 */ +@Slf4j +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +@CrossOrigin +public class StaffInvitationController { + + private final StaffInvitationService invitationService; + private final WechatMiniProgramService wechatMiniProgramService; + + @PostMapping("/admin/staff-invitations") + public Map create(@RequestBody Map params) { + CurrentUser user = CurrentUserContext.require(); + if (!user.isBoss() || user.storeId() == null) { + return error(403, "FORBIDDEN", "仅老板可邀请员工"); + } + try { + Integer validDays = integerOrNull(params.get("validDays")); + Map data = invitationService.create( + user.storeId(), user.userId(), user.role(), text(params.get("name")), + text(params.get("phone")), validDays + ); + return Map.of("code", 200, "message", "邀请已创建,请立即复制邀请链接", "data", data); + } catch (FlowException e) { + return error(e.code(), e.bizCode(), e.getMessage()); + } catch (NumberFormatException e) { + return error(400, "INVALID_EXPIRY", "邀请有效期格式不正确"); + } + } + + @GetMapping("/admin/staff-invitations") + public Map list() { + CurrentUser user = CurrentUserContext.require(); + if (!user.isBoss() || user.storeId() == null) { + return error(403, "FORBIDDEN", "仅老板可查看员工邀请"); + } + try { + return Map.of("code", 200, "data", invitationService.list(user.storeId())); + } catch (FlowException e) { + return error(e.code(), e.bizCode(), e.getMessage()); + } + } + + @DeleteMapping("/admin/staff-invitations") + public Map revoke(@RequestParam String invitationId) { + CurrentUser user = CurrentUserContext.require(); + if (!user.isBoss() || user.storeId() == null) { + return error(403, "FORBIDDEN", "仅老板可撤销员工邀请"); + } + try { + return Map.of( + "code", 200, + "message", "邀请已撤销", + "data", invitationService.revoke(user.storeId(), user.userId(), user.role(), invitationId) + ); + } catch (FlowException e) { + return error(e.code(), e.bizCode(), e.getMessage()); + } + } + + @GetMapping("/staff-invitations/preview") + public Map preview(@RequestParam String token) { + try { + return Map.of("code", 200, "data", invitationService.preview(token)); + } catch (FlowException e) { + return error(e.code(), e.bizCode(), e.getMessage()); + } + } + + @PostMapping("/staff-invitations/accept") + public Map accept(@RequestBody(required = false) Map params) { + if (params == null || params.get("token") == null || params.get("phoneCode") == null) { + return error(400, "INVITATION_VERIFICATION_REQUIRED", "缺少邀请凭证或微信手机号授权码"); + } + var phoneResult = wechatMiniProgramService.exchangePhoneCode(params.get("phoneCode")); + if (!phoneResult.isOk()) { + return error(400, "WECHAT_PHONE_FAILED", phoneResult.errorMessage()); + } + String openid = null; + String unionid = null; + String loginCode = params.get("loginCode"); + if (loginCode != null && !loginCode.isBlank()) { + var identity = wechatMiniProgramService.exchangeJsCode(loginCode); + if (identity.isOk()) { + openid = identity.openid(); + unionid = identity.unionid(); + } else { + log.debug("员工接受邀请时 openid 沉淀跳过: {}", identity.errorMessage()); + } + } + try { + Map data = invitationService.acceptVerified( + params.get("token"), phoneResult.phone(), openid, unionid + ); + return Map.of("code", 200, "message", "已加入门店", "data", data); + } catch (FlowException e) { + return error(e.code(), e.bizCode(), e.getMessage()); + } + } + + private static String text(Object value) { + return value == null ? null : value.toString(); + } + + private static Integer integerOrNull(Object value) { + return value == null || value.toString().isBlank() ? null : Integer.valueOf(value.toString()); + } + + private static Map error(int code, String bizCode, String message) { + Map result = new LinkedHashMap<>(); + result.put("code", code); + result.put("bizCode", bizCode); + result.put("message", message == null || message.isBlank() ? "操作失败" : message); + return result; + } +} diff --git a/src/main/java/com/petstore/controller/StoreController.java b/src/main/java/com/petstore/controller/StoreController.java index 2fc89c6..7cd8ebf 100644 --- a/src/main/java/com/petstore/controller/StoreController.java +++ b/src/main/java/com/petstore/controller/StoreController.java @@ -4,6 +4,7 @@ import com.petstore.auth.CurrentUser; import com.petstore.auth.CurrentUserContext; import com.petstore.entity.Store; import com.petstore.service.StoreService; +import com.petstore.service.AuditLogService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -18,15 +19,15 @@ import java.util.stream.Collectors; @CrossOrigin public class StoreController { private final StoreService storeService; + private final AuditLogService auditLogService; @PostMapping("/register") public Map register(@RequestBody Store store) { - Store created = storeService.create(store); - Map result = new HashMap<>(); - result.put("code", 200); - result.put("message", "注册成功"); - result.put("data", created); - return result; + return Map.of( + "code", 410, + "message", "旧门店创建入口已停用,请使用微信核验手机号完成门店开通", + "bizCode", "LEGACY_STORE_REGISTRATION_DISABLED" + ); } /** @@ -100,6 +101,15 @@ public class StoreController { if (updated == null) { return Map.of("code", 404, "message", "店铺不存在"); } + boolean profileChanged = store.getName() != null || store.getPhone() != null || store.getAddress() != null + || store.getIntro() != null || store.getLogo() != null + || store.getLatitude() != null || store.getLongitude() != null; + boolean bookingChanged = store.getBookingDayStart() != null || store.getBookingLastSlotStart() != null + || store.getBookingCapacity() != null; + auditLogService.record( + u.storeId(), u.userId(), u.role(), "store_settings_updated", "store", u.storeId().toString(), + "success", Map.of("profileChanged", profileChanged, "bookingChanged", bookingChanged) + ); Map result = new HashMap<>(); result.put("code", 200); result.put("message", "更新成功"); @@ -127,8 +137,7 @@ public class StoreController { } /** - * 按邀请码查门店:仅 boss/staff 可查,且邀请码对应的门店必须为当前用户所属门店。 - * 新员工注册预览门店请走公开接口 {@code POST /api/user/register-staff}(请求体含 inviteCode,服务端内部校验)。 + * legacy 永久邀请码不再作为员工凭证;保留入口只为明确返回迁移提示。 */ @GetMapping("/invite-code") public Map getByInviteCode(@RequestParam String code) { @@ -136,16 +145,10 @@ public class StoreController { if (!u.isStoreUser() || u.storeId() == null) { return Map.of("code", 403, "message", "仅门店员工可按邀请码查询", "bizCode", "FORBIDDEN"); } - Store store = storeService.findByInviteCode(code); - Map result = new HashMap<>(); - if (store == null) { - return Map.of("code", 404, "message", "邀请码无效"); - } - if (!store.getId().equals(u.storeId())) { - return Map.of("code", 403, "message", "邀请码不属于本店", "bizCode", "FORBIDDEN"); - } - result.put("code", 200); - result.put("data", store); - return result; + return Map.of( + "code", 410, + "message", "永久邀请码已停用,请使用限时员工邀请", + "bizCode", "LEGACY_INVITE_DISABLED" + ); } } diff --git a/src/main/java/com/petstore/controller/UserController.java b/src/main/java/com/petstore/controller/UserController.java index d746e97..74f9a64 100644 --- a/src/main/java/com/petstore/controller/UserController.java +++ b/src/main/java/com/petstore/controller/UserController.java @@ -5,6 +5,7 @@ import com.petstore.auth.CurrentUserContext; import com.petstore.entity.User; import com.petstore.service.UserService; import com.petstore.service.WechatMiniProgramService; +import com.petstore.service.AuditLogService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; @@ -21,15 +22,16 @@ import java.util.Map; public class UserController { private final UserService userService; private final WechatMiniProgramService wechatMiniProgramService; + private final AuditLogService auditLogService; /** 老板注册店铺 */ @PostMapping("/register-boss") public Map registerBoss(@RequestBody Map params) { - String storeName = params.get("storeName"); - String bossName = params.get("bossName"); - String phone = params.get("phone"); - String password = params.get("password"); - return userService.registerBoss(storeName, bossName, phone, password); + return Map.of( + "code", 410, + "message", "旧注册入口已停用,请使用微信核验手机号完成门店开通", + "bizCode", "LEGACY_REGISTRATION_DISABLED" + ); } /** 登录(老板/员工) */ @@ -98,11 +100,11 @@ public class UserController { /** 员工注册(邀请码方式) */ @PostMapping("/register-staff") public Map registerStaff(@RequestBody Map params) { - String phone = params.get("phone"); - String password = params.get("password"); - String name = params.get("name"); - String inviteCode = params.get("inviteCode"); - return userService.registerStaff(phone, password, name, inviteCode); + return Map.of( + "code", 410, + "message", "永久邀请码注册已停用,请让门店老板重新发送限时邀请", + "bizCode", "LEGACY_INVITE_DISABLED" + ); } /** 老板:创建员工;storeId 从上下文派生,仅 boss。 */ @@ -112,9 +114,11 @@ public class UserController { if (!u.isBoss() || u.storeId() == null) { return Map.of("code", 403, "message", "仅老板可创建员工", "bizCode", "FORBIDDEN"); } - String name = params.get("name").toString(); - String phone = params.get("phone").toString(); - return userService.createStaff(u.storeId(), name, phone); + return Map.of( + "code", 410, + "message", "直接创建员工已停用,请创建限时员工邀请", + "bizCode", "DIRECT_STAFF_CREATION_DISABLED" + ); } /** 老板/员工:员工列表;storeId 从上下文派生。 */ @@ -141,11 +145,18 @@ public class UserController { if (target == null || target.getStoreId() == null || !target.getStoreId().equals(u.storeId())) { return Map.of("code", 403, "message", "无权删除他店员工", "bizCode", "FORBIDDEN"); } + if (!"staff".equals(target.getRole())) { + return Map.of("code", 403, "message", "只能删除员工账号", "bizCode", "FORBIDDEN"); + } boolean ok = userService.deleteStaff(staffId); if (!ok) { return Map.of("code", 404, "message", "员工不存在"); } - return Map.of("code", 200, "message", "删除成功"); + auditLogService.record( + u.storeId(), u.userId(), u.role(), "staff_account_removed", "user", staffId.toString(), + "success", Map.of("removedRole", "staff") + ); + return Map.of("code", 200, "message", "删除成功,员工现有会话已失效"); } /** 获取当前登录用户信息(userId 从上下文派生,不信任 query)。 */ diff --git a/src/main/java/com/petstore/entity/AuditLog.java b/src/main/java/com/petstore/entity/AuditLog.java new file mode 100644 index 0000000..fc5785e --- /dev/null +++ b/src/main/java/com/petstore/entity/AuditLog.java @@ -0,0 +1,68 @@ +package com.petstore.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 不可变操作审计。只记录安全、权限和门店配置类操作,不承载业务对象当前状态。 + * metadataJson 只能由服务端白名单字段构造,禁止写手机号、凭证、请求体和第三方身份标识。 + */ +@Data +@Entity +@Table( + name = "t_audit_log", + uniqueConstraints = @UniqueConstraint(name = "uk_audit_log_audit_id", columnNames = "audit_id"), + indexes = { + @Index(name = "idx_audit_store_time", columnList = "store_id,occurred_at"), + @Index(name = "idx_audit_action_time", columnList = "action,occurred_at"), + @Index(name = "idx_audit_target", columnList = "target_type,target_id") + } +) +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "audit_id", nullable = false, length = 36) + private String auditId; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "actor_user_id") + private Long actorUserId; + + @Column(name = "actor_role", length = 16) + private String actorRole; + + @Column(nullable = false, length = 64) + private String action; + + @Column(name = "target_type", nullable = false, length = 32) + private String targetType; + + @Column(name = "target_id", length = 64) + private String targetId; + + @Column(nullable = false, length = 16) + private String outcome; + + @Column(name = "metadata_json", nullable = false, columnDefinition = "TEXT") + private String metadataJson; + + @Column(name = "occurred_at", nullable = false) + private LocalDateTime occurredAt; + + @Column(name = "create_time", nullable = false) + private LocalDateTime createTime; +} diff --git a/src/main/java/com/petstore/entity/StaffInvitation.java b/src/main/java/com/petstore/entity/StaffInvitation.java new file mode 100644 index 0000000..b8252c8 --- /dev/null +++ b/src/main/java/com/petstore/entity/StaffInvitation.java @@ -0,0 +1,80 @@ +package com.petstore.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 老板签发的限时、一次性员工邀请。原始 token 不落库,只保存 SHA-256 摘要。 */ +@Data +@Entity +@Table( + name = "t_staff_invitation", + uniqueConstraints = { + @UniqueConstraint(name = "uk_staff_invitation_public_id", columnNames = "invitation_id"), + @UniqueConstraint(name = "uk_staff_invitation_token_hash", columnNames = "token_hash") + }, + indexes = { + @Index(name = "idx_staff_invite_store_status", columnList = "store_id,status,expires_at"), + @Index(name = "idx_staff_invite_phone", columnList = "store_id,invited_phone,status") + } +) +public class StaffInvitation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "invitation_id", nullable = false, length = 36) + private String invitationId; + + @JsonIgnore + @Column(name = "token_hash", nullable = false, length = 64) + private String tokenHash; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "invited_name", nullable = false, length = 64) + private String invitedName; + + @JsonIgnore + @Column(name = "invited_phone", nullable = false, length = 20) + private String invitedPhone; + + /** pending / accepted / revoked / expired */ + @Column(nullable = false, length = 16) + private String status; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "created_by_user_id", nullable = false) + private Long createdByUserId; + + @Column(name = "accepted_by_user_id") + private Long acceptedByUserId; + + @Column(name = "accepted_at") + private LocalDateTime acceptedAt; + + @Column(name = "revoked_by_user_id") + private Long revokedByUserId; + + @Column(name = "revoked_at") + private LocalDateTime revokedAt; + + @Column(name = "create_time", nullable = false) + private LocalDateTime createTime; + + @Column(name = "update_time", nullable = false) + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/petstore/entity/Store.java b/src/main/java/com/petstore/entity/Store.java index 0d183ff..64c21bc 100644 --- a/src/main/java/com/petstore/entity/Store.java +++ b/src/main/java/com/petstore/entity/Store.java @@ -53,6 +53,16 @@ public class Store { /** 同一半小时内可并行服务的顾客数,默认 1。 */ @Column(name = "booking_capacity", nullable = false) private Integer bookingCapacity; + + /** unknown(历史)/ in_progress / completed。 */ + @Column(name = "onboarding_status", nullable = false, length = 16) + private String onboardingStatus = "in_progress"; + + @Column(name = "onboarding_completed_at") + private LocalDateTime onboardingCompletedAt; + + @Column(name = "onboarding_completed_by_user_id") + private Long onboardingCompletedByUserId; @Column(name = "create_time") private LocalDateTime createTime; diff --git a/src/main/java/com/petstore/mapper/AuditLogMapper.java b/src/main/java/com/petstore/mapper/AuditLogMapper.java new file mode 100644 index 0000000..790af45 --- /dev/null +++ b/src/main/java/com/petstore/mapper/AuditLogMapper.java @@ -0,0 +1,7 @@ +package com.petstore.mapper; + +import com.petstore.entity.AuditLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AuditLogMapper extends JpaRepository { +} diff --git a/src/main/java/com/petstore/mapper/StaffInvitationMapper.java b/src/main/java/com/petstore/mapper/StaffInvitationMapper.java new file mode 100644 index 0000000..fd865c0 --- /dev/null +++ b/src/main/java/com/petstore/mapper/StaffInvitationMapper.java @@ -0,0 +1,31 @@ +package com.petstore.mapper; + +import com.petstore.entity.StaffInvitation; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface StaffInvitationMapper extends JpaRepository { + + Optional findFirstByTokenHash(String tokenHash); + + List findByStoreIdOrderByCreateTimeDesc(Long storeId); + + List findByStoreIdAndInvitedPhoneAndStatus(Long storeId, String invitedPhone, String status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT i FROM StaffInvitation i WHERE i.tokenHash = :tokenHash") + Optional findByTokenHashForUpdate(@Param("tokenHash") String tokenHash); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT i FROM StaffInvitation i WHERE i.invitationId = :invitationId AND i.storeId = :storeId") + Optional findByInvitationIdAndStoreIdForUpdate( + @Param("invitationId") String invitationId, + @Param("storeId") Long storeId + ); +} diff --git a/src/main/java/com/petstore/service/AuditLogService.java b/src/main/java/com/petstore/service/AuditLogService.java new file mode 100644 index 0000000..b0e3e12 --- /dev/null +++ b/src/main/java/com/petstore/service/AuditLogService.java @@ -0,0 +1,101 @@ +package com.petstore.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.petstore.entity.AuditLog; +import com.petstore.mapper.AuditLogMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** 统一写入低敏、不可变的操作审计。 */ +@Service +@RequiredArgsConstructor +public class AuditLogService { + + private static final Set FORBIDDEN_METADATA_FRAGMENTS = Set.of( + "phone", "token", "url", "openid", "unionid", "password", "secret", + "remark", "content", "request", "payload", "address", "latitude", "longitude" + ); + + private final AuditLogMapper auditLogMapper; + private final ObjectMapper objectMapper; + + public AuditLog record( + Long storeId, + Long actorUserId, + String actorRole, + String action, + String targetType, + String targetId, + String outcome, + Map metadata + ) { + if (storeId == null) { + throw new IllegalArgumentException("审计记录必须归属门店"); + } + AuditLog log = new AuditLog(); + log.setAuditId(UUID.randomUUID().toString()); + log.setStoreId(storeId); + log.setActorUserId(actorUserId); + log.setActorRole(normalizeOptional(actorRole, 16)); + log.setAction(normalizeRequired(action, 64)); + log.setTargetType(normalizeRequired(targetType, 32)); + log.setTargetId(normalizeOptional(targetId, 64)); + log.setOutcome(normalizeRequired(outcome == null ? "success" : outcome, 16)); + log.setMetadataJson(serializeMetadata(metadata)); + log.setOccurredAt(LocalDateTime.now()); + log.setCreateTime(LocalDateTime.now()); + return auditLogMapper.save(log); + } + + private String serializeMetadata(Map metadata) { + Map safe = new LinkedHashMap<>(); + if (metadata != null) { + for (Map.Entry entry : metadata.entrySet()) { + String key = entry.getKey() == null ? "" : entry.getKey().trim(); + String normalizedKey = key.toLowerCase(Locale.ROOT).replace("_", ""); + boolean forbidden = FORBIDDEN_METADATA_FRAGMENTS.stream().anyMatch(normalizedKey::contains) + || "ip".equals(normalizedKey); + if (key.isBlank() || forbidden) { + throw new IllegalArgumentException("审计 metadata 包含禁止字段: " + key); + } + Object value = entry.getValue(); + if (value == null || value instanceof Boolean || value instanceof Number) { + safe.put(key, value); + } else if (value instanceof String text) { + safe.put(key, text.length() <= 100 ? text : text.substring(0, 100)); + } else { + throw new IllegalArgumentException("审计 metadata 仅允许标量值"); + } + } + } + try { + return objectMapper.writeValueAsString(safe); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("审计 metadata 无法序列化", e); + } + } + + private static String normalizeRequired(String value, int maxLength) { + String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + if (normalized.isBlank()) { + throw new IllegalArgumentException("审计必填字段为空"); + } + return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength); + } + + private static String normalizeOptional(String value, int maxLength) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength); + } +} diff --git a/src/main/java/com/petstore/service/BusinessEventService.java b/src/main/java/com/petstore/service/BusinessEventService.java index 119b133..faf1778 100644 --- a/src/main/java/com/petstore/service/BusinessEventService.java +++ b/src/main/java/com/petstore/service/BusinessEventService.java @@ -7,6 +7,7 @@ import com.petstore.entity.BusinessEvent; import com.petstore.entity.Report; import com.petstore.entity.ReportLead; import com.petstore.entity.StoreCustomer; +import com.petstore.entity.Store; import com.petstore.mapper.BusinessEventMapper; import com.petstore.mapper.ReportMapper; import com.petstore.mapper.StoreCustomerMapper; @@ -35,6 +36,7 @@ public class BusinessEventService { public static final String REPORT_OPENED = "report_opened"; public static final String REPORT_REOPENED = "report_reopened"; public static final String LEAD_SUBMITTED = "lead_submitted"; + public static final String STORE_REGISTERED = "store_registered"; private static final Set FORBIDDEN_METADATA_FRAGMENTS = Set.of( "phone", "token", "url", "openid", "unionid", @@ -46,6 +48,23 @@ public class BusinessEventService { private final ReportMapper reportMapper; private final ObjectMapper objectMapper; + @Transactional + public BusinessEvent recordStoreRegistered(Store store, Long actorUserId) { + return record(new EventCommand( + STORE_REGISTERED, + store.getId(), + null, + "store", + store.getId(), + actorUserId, + "boss", + "admin", + store.getCreateTime(), + "store_registered:" + store.getId(), + Map.of() + )); + } + @Transactional public BusinessEvent recordAppointmentCreated( Appointment appointment, diff --git a/src/main/java/com/petstore/service/FlowException.java b/src/main/java/com/petstore/service/FlowException.java new file mode 100644 index 0000000..2382ae9 --- /dev/null +++ b/src/main/java/com/petstore/service/FlowException.java @@ -0,0 +1,21 @@ +package com.petstore.service; + +/** 面向 API 的可预期流程错误;不用于系统异常。 */ +public class FlowException extends RuntimeException { + private final int code; + private final String bizCode; + + public FlowException(int code, String bizCode, String message) { + super(message); + this.code = code; + this.bizCode = bizCode; + } + + public int code() { + return code; + } + + public String bizCode() { + return bizCode; + } +} diff --git a/src/main/java/com/petstore/service/MerchantOnboardingService.java b/src/main/java/com/petstore/service/MerchantOnboardingService.java new file mode 100644 index 0000000..0bd7f08 --- /dev/null +++ b/src/main/java/com/petstore/service/MerchantOnboardingService.java @@ -0,0 +1,126 @@ +package com.petstore.service; + +import com.petstore.auth.SessionTokenService; +import com.petstore.entity.Store; +import com.petstore.entity.User; +import com.petstore.mapper.StoreMapper; +import com.petstore.mapper.UserMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** 通过微信核验手机号创建门店与老板账号,替代无验证的 legacy 注册接口。 */ +@Service +@RequiredArgsConstructor +public class MerchantOnboardingService { + + private final UserMapper userMapper; + private final StoreMapper storeMapper; + private final SessionTokenService sessionTokenService; + private final BusinessEventService businessEventService; + private final AuditLogService auditLogService; + + @Transactional + public Map registerVerifiedBoss( + String storeName, + String bossName, + String verifiedPhone, + String wechatOpenid, + String wechatUnionid + ) { + String storeNameNorm = required(storeName, 64, "请填写门店名称"); + String bossNameNorm = required(bossName, 64, "请填写老板姓名"); + if (verifiedPhone == null || !verifiedPhone.matches("^1[3-9]\\d{9}$")) { + throw new FlowException(400, "INVALID_PHONE", "微信手机号格式不正确"); + } + if (userMapper.findByPhoneAndDeletedFalse(verifiedPhone) != null) { + throw new FlowException(409, "PHONE_ALREADY_REGISTERED", "该手机号已有账号,请直接登录"); + } + assertWechatIdentityAvailable(wechatOpenid, wechatUnionid); + + LocalDateTime now = LocalDateTime.now(); + Store store = new Store(); + store.setName(storeNameNorm); + store.setPhone(verifiedPhone); + store.setOwnerId(0L); + // legacy 兼容字段,不再作为员工注册凭证,也不返回客户端。 + store.setInviteCode(UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase()); + store.setBookingDayStart(LocalTime.of(9, 0)); + store.setBookingLastSlotStart(LocalTime.of(21, 30)); + store.setBookingCapacity(1); + store.setOnboardingStatus("in_progress"); + store.setCreateTime(now); + store.setUpdateTime(now); + store.setDeleted(false); + store = storeMapper.save(store); + + User boss = new User(); + boss.setUsername(verifiedPhone); + boss.setName(bossNameNorm); + boss.setPhone(verifiedPhone); + boss.setPassword("wx_no_password"); + boss.setStoreId(store.getId()); + boss.setRole("boss"); + boss.setWechatOpenid(blankToNull(wechatOpenid)); + boss.setWechatUnionid(blankToNull(wechatUnionid)); + boss.setCreateTime(now); + boss.setUpdateTime(now); + boss.setDeleted(false); + boss = userMapper.save(boss); + + store.setOwnerId(boss.getId()); + store = storeMapper.save(store); + + businessEventService.recordStoreRegistered(store, boss.getId()); + auditLogService.record( + store.getId(), boss.getId(), "boss", "store_registered", "store", store.getId().toString(), + "success", Map.of("verificationMethod", "wechat_phone", "onboardingStatus", "in_progress") + ); + + Map userView = new LinkedHashMap<>(); + userView.put("id", boss.getId()); + userView.put("name", boss.getName()); + userView.put("phone", boss.getPhone()); + userView.put("storeId", boss.getStoreId()); + userView.put("role", boss.getRole()); + + Map storeView = new LinkedHashMap<>(); + storeView.put("id", store.getId()); + storeView.put("name", store.getName()); + storeView.put("phone", store.getPhone()); + storeView.put("onboardingStatus", store.getOnboardingStatus()); + + Map data = new LinkedHashMap<>(); + data.put("user", userView); + data.put("store", storeView); + data.put("sessionToken", sessionTokenService.issue(boss.getId(), store.getId(), "boss")); + return data; + } + + private void assertWechatIdentityAvailable(String openid, String unionid) { + if (openid != null && !openid.isBlank() && userMapper.findByWechatOpenidAndDeletedFalse(openid).isPresent()) { + throw new FlowException(409, "WECHAT_ACCOUNT_CONFLICT", "该微信已绑定其他账号,请直接登录或更换微信"); + } + if (unionid != null && !unionid.isBlank() && userMapper.findByWechatUnionidAndDeletedFalse(unionid).isPresent()) { + throw new FlowException(409, "WECHAT_ACCOUNT_CONFLICT", "该微信已绑定其他账号,请直接登录或更换微信"); + } + } + + private static String required(String value, int max, String message) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isBlank()) { + throw new FlowException(400, "INVALID_ONBOARDING_INPUT", message); + } + return normalized.length() <= max ? normalized : normalized.substring(0, max); + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/src/main/java/com/petstore/service/StaffInvitationService.java b/src/main/java/com/petstore/service/StaffInvitationService.java new file mode 100644 index 0000000..1b93bee --- /dev/null +++ b/src/main/java/com/petstore/service/StaffInvitationService.java @@ -0,0 +1,337 @@ +package com.petstore.service; + +import com.petstore.auth.SessionTokenService; +import com.petstore.entity.StaffInvitation; +import com.petstore.entity.Store; +import com.petstore.entity.User; +import com.petstore.mapper.StaffInvitationMapper; +import com.petstore.mapper.StoreMapper; +import com.petstore.mapper.UserMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** 限时、一次性、手机号绑定的员工邀请。 */ +@Service +@RequiredArgsConstructor +public class StaffInvitationService { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final Base64.Encoder TOKEN_ENCODER = Base64.getUrlEncoder().withoutPadding(); + + private final StaffInvitationMapper invitationMapper; + private final StoreMapper storeMapper; + private final UserMapper userMapper; + private final SessionTokenService sessionTokenService; + private final AuditLogService auditLogService; + + @Transactional + public Map create( + Long storeId, + Long actorUserId, + String actorRole, + String invitedName, + String invitedPhone, + Integer validDays + ) { + Store store = storeMapper.findByIdAndDeletedFalse(storeId).orElse(null); + if (store == null) { + throw new FlowException(404, "STORE_NOT_FOUND", "门店不存在"); + } + String name = required(invitedName, 64, "请填写员工姓名"); + String phone = invitedPhone == null ? "" : invitedPhone.trim(); + if (!phone.matches("^1[3-9]\\d{9}$")) { + throw new FlowException(400, "INVALID_PHONE", "请填写正确的员工手机号"); + } + if (userMapper.findByPhoneAndDeletedFalse(phone) != null) { + throw new FlowException(409, "ACCOUNT_ALREADY_EXISTS", "该手机号已有账号,不能重复邀请"); + } + int days = validDays == null ? 7 : validDays; + if (days < 1 || days > 30) { + throw new FlowException(400, "INVALID_EXPIRY", "邀请有效期须为 1~30 天"); + } + + LocalDateTime now = LocalDateTime.now(); + int superseded = 0; + for (StaffInvitation previous : invitationMapper + .findByStoreIdAndInvitedPhoneAndStatus(storeId, phone, "pending")) { + previous.setStatus(now.isAfter(previous.getExpiresAt()) ? "expired" : "revoked"); + if ("revoked".equals(previous.getStatus())) { + previous.setRevokedByUserId(actorUserId); + previous.setRevokedAt(now); + superseded++; + } + previous.setUpdateTime(now); + invitationMapper.save(previous); + } + + byte[] tokenBytes = new byte[32]; + SECURE_RANDOM.nextBytes(tokenBytes); + String rawToken = TOKEN_ENCODER.encodeToString(tokenBytes); + + StaffInvitation invitation = new StaffInvitation(); + invitation.setInvitationId(UUID.randomUUID().toString()); + invitation.setTokenHash(hashToken(rawToken)); + invitation.setStoreId(storeId); + invitation.setInvitedName(name); + invitation.setInvitedPhone(phone); + invitation.setStatus("pending"); + invitation.setExpiresAt(now.plusDays(days)); + invitation.setCreatedByUserId(actorUserId); + invitation.setCreateTime(now); + invitation.setUpdateTime(now); + invitation = invitationMapper.save(invitation); + + auditLogService.record( + storeId, actorUserId, actorRole, "staff_invitation_created", "staff_invitation", + invitation.getInvitationId(), "success", + Map.of("validDays", days, "supersededInvitationCount", superseded) + ); + + Map result = toView(invitation, store.getName()); + result.put("inviteToken", rawToken); + result.put("invitePath", "/pages/login/Login?staffInvite=" + rawToken); + return result; + } + + @Transactional + public List> list(Long storeId) { + LocalDateTime now = LocalDateTime.now(); + Store store = storeMapper.findByIdAndDeletedFalse(storeId).orElse(null); + if (store == null) { + throw new FlowException(404, "STORE_NOT_FOUND", "门店不存在"); + } + List> result = new ArrayList<>(); + for (StaffInvitation invitation : invitationMapper.findByStoreIdOrderByCreateTimeDesc(storeId)) { + if ("pending".equals(invitation.getStatus()) && now.isAfter(invitation.getExpiresAt())) { + invitation.setStatus("expired"); + invitation.setUpdateTime(now); + invitationMapper.save(invitation); + } + result.add(toView(invitation, store.getName())); + } + return result; + } + + public Map preview(String rawToken) { + StaffInvitation invitation = invitationMapper.findFirstByTokenHash(hashToken(rawToken)).orElse(null); + if (invitation == null) { + throw new FlowException(404, "INVITATION_NOT_FOUND", "邀请不存在或已失效"); + } + Store store = storeMapper.findByIdAndDeletedFalse(invitation.getStoreId()).orElse(null); + if (store == null) { + throw new FlowException(404, "INVITATION_NOT_FOUND", "邀请不存在或已失效"); + } + Map result = toView(invitation, store.getName()); + String status = effectiveStatus(invitation, LocalDateTime.now()); + result.put("status", status); + // 已接受邀请只允许原账号再次核验同一微信手机号,幂等恢复登录态,不创建第二个账号。 + result.put("canAccept", "pending".equals(status) || "accepted".equals(status)); + return result; + } + + @Transactional + public Map revoke( + Long storeId, + Long actorUserId, + String actorRole, + String invitationId + ) { + StaffInvitation invitation = invitationMapper + .findByInvitationIdAndStoreIdForUpdate(invitationId, storeId) + .orElse(null); + if (invitation == null) { + throw new FlowException(404, "INVITATION_NOT_FOUND", "邀请不存在"); + } + String status = effectiveStatus(invitation, LocalDateTime.now()); + if ("accepted".equals(status)) { + throw new FlowException(409, "INVITATION_ALREADY_ACCEPTED", "员工已加入,不能撤销邀请"); + } + boolean alreadyRevoked = "revoked".equals(status); + if (!alreadyRevoked) { + LocalDateTime now = LocalDateTime.now(); + invitation.setStatus("expired".equals(status) ? "expired" : "revoked"); + if ("revoked".equals(invitation.getStatus())) { + invitation.setRevokedByUserId(actorUserId); + invitation.setRevokedAt(now); + } + invitation.setUpdateTime(now); + invitation = invitationMapper.save(invitation); + auditLogService.record( + storeId, actorUserId, actorRole, "staff_invitation_revoked", "staff_invitation", + invitation.getInvitationId(), "success", Map.of() + ); + } + Store store = storeMapper.findByIdAndDeletedFalse(storeId).orElseThrow(); + Map result = toView(invitation, store.getName()); + result.put("alreadyRevoked", alreadyRevoked); + return result; + } + + @Transactional + public Map acceptVerified( + String rawToken, + String verifiedPhone, + String wechatOpenid, + String wechatUnionid + ) { + StaffInvitation invitation = invitationMapper.findByTokenHashForUpdate(hashToken(rawToken)).orElse(null); + if (invitation == null) { + throw new FlowException(404, "INVITATION_NOT_FOUND", "邀请不存在或已失效"); + } + String status = effectiveStatus(invitation, LocalDateTime.now()); + if ("revoked".equals(status)) { + throw new FlowException(409, "INVITATION_REVOKED", "邀请已撤销,请联系门店老板重新邀请"); + } + if ("expired".equals(status)) { + throw new FlowException(409, "INVITATION_EXPIRED", "邀请已过期,请联系门店老板重新邀请"); + } + if (verifiedPhone == null || !verifiedPhone.equals(invitation.getInvitedPhone())) { + throw new FlowException(403, "INVITATION_PHONE_MISMATCH", "微信手机号与受邀手机号不一致"); + } + Store store = storeMapper.findByIdAndDeletedFalse(invitation.getStoreId()).orElse(null); + if (store == null) { + throw new FlowException(404, "STORE_NOT_FOUND", "受邀门店不存在"); + } + + if ("accepted".equals(status)) { + User accepted = invitation.getAcceptedByUserId() == null ? null + : userMapper.findByIdAndDeletedFalse(invitation.getAcceptedByUserId()).orElse(null); + if (accepted == null + || !verifiedPhone.equals(accepted.getPhone()) + || !invitation.getStoreId().equals(accepted.getStoreId()) + || !"staff".equals(accepted.getRole())) { + throw new FlowException(409, "INVITATION_ALREADY_ACCEPTED", "邀请已被使用"); + } + return loginView(accepted, store, true); + } + + if (userMapper.findByPhoneAndDeletedFalse(verifiedPhone) != null) { + throw new FlowException(409, "ACCOUNT_ALREADY_EXISTS", "该手机号已有账号,不能接受员工邀请"); + } + assertWechatIdentityAvailable(wechatOpenid, wechatUnionid); + + LocalDateTime now = LocalDateTime.now(); + User staff = new User(); + staff.setUsername(verifiedPhone); + staff.setPhone(verifiedPhone); + staff.setName(invitation.getInvitedName()); + staff.setPassword("wx_no_password"); + staff.setStoreId(invitation.getStoreId()); + staff.setRole("staff"); + staff.setWechatOpenid(blankToNull(wechatOpenid)); + staff.setWechatUnionid(blankToNull(wechatUnionid)); + staff.setCreateTime(now); + staff.setUpdateTime(now); + staff.setDeleted(false); + staff = userMapper.save(staff); + + invitation.setStatus("accepted"); + invitation.setAcceptedByUserId(staff.getId()); + invitation.setAcceptedAt(now); + invitation.setUpdateTime(now); + invitationMapper.save(invitation); + + auditLogService.record( + invitation.getStoreId(), staff.getId(), "staff", "staff_invitation_accepted", "staff_invitation", + invitation.getInvitationId(), "success", + Map.of("verificationMethod", "wechat_phone", "accountRole", "staff") + ); + return loginView(staff, store, false); + } + + private Map loginView(User user, Store store, boolean alreadyAccepted) { + Map userView = new LinkedHashMap<>(); + userView.put("id", user.getId()); + userView.put("name", user.getName()); + userView.put("phone", user.getPhone()); + userView.put("storeId", user.getStoreId()); + userView.put("role", user.getRole()); + + Map storeView = new LinkedHashMap<>(); + storeView.put("id", store.getId()); + storeView.put("name", store.getName()); + storeView.put("phone", store.getPhone()); + + Map result = new LinkedHashMap<>(); + result.put("user", userView); + result.put("store", storeView); + result.put("sessionToken", sessionTokenService.issue(user.getId(), user.getStoreId(), user.getRole())); + result.put("alreadyAccepted", alreadyAccepted); + return result; + } + + private void assertWechatIdentityAvailable(String openid, String unionid) { + if (openid != null && !openid.isBlank() && userMapper.findByWechatOpenidAndDeletedFalse(openid).isPresent()) { + throw new FlowException(409, "WECHAT_ACCOUNT_CONFLICT", "该微信已绑定其他账号,请使用受邀员工本人的微信"); + } + if (unionid != null && !unionid.isBlank() && userMapper.findByWechatUnionidAndDeletedFalse(unionid).isPresent()) { + throw new FlowException(409, "WECHAT_ACCOUNT_CONFLICT", "该微信已绑定其他账号,请使用受邀员工本人的微信"); + } + } + + private static Map toView(StaffInvitation invitation, String storeName) { + Map result = new LinkedHashMap<>(); + result.put("invitationId", invitation.getInvitationId()); + result.put("storeName", storeName); + result.put("invitedName", invitation.getInvitedName()); + result.put("maskedPhone", maskPhone(invitation.getInvitedPhone())); + result.put("status", effectiveStatus(invitation, LocalDateTime.now())); + result.put("expiresAt", invitation.getExpiresAt()); + result.put("acceptedAt", invitation.getAcceptedAt()); + result.put("createdAt", invitation.getCreateTime()); + return result; + } + + private static String effectiveStatus(StaffInvitation invitation, LocalDateTime now) { + if ("pending".equals(invitation.getStatus()) + && invitation.getExpiresAt() != null + && now.isAfter(invitation.getExpiresAt())) { + return "expired"; + } + return invitation.getStatus(); + } + + private static String maskPhone(String phone) { + if (phone == null || phone.length() < 7) return "***"; + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + + private static String required(String value, int max, String message) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isBlank()) { + throw new FlowException(400, "INVALID_INVITATION", message); + } + return normalized.length() <= max ? normalized : normalized.substring(0, max); + } + + static String hashToken(String rawToken) { + String normalized = rawToken == null ? "" : rawToken.trim(); + if (normalized.isBlank() || normalized.length() > 512) { + throw new FlowException(404, "INVITATION_NOT_FOUND", "邀请不存在或已失效"); + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(normalized.getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("运行环境不支持 SHA-256", e); + } + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/src/main/java/com/petstore/service/StoreOnboardingService.java b/src/main/java/com/petstore/service/StoreOnboardingService.java new file mode 100644 index 0000000..7139675 --- /dev/null +++ b/src/main/java/com/petstore/service/StoreOnboardingService.java @@ -0,0 +1,126 @@ +package com.petstore.service; + +import com.petstore.entity.ServiceType; +import com.petstore.entity.Store; +import com.petstore.entity.User; +import com.petstore.mapper.ServiceTypeMapper; +import com.petstore.mapper.StoreMapper; +import com.petstore.mapper.UserMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** 门店开通就绪清单。完成状态必须由老板显式确认,历史门店保持 unknown。 */ +@Service +@RequiredArgsConstructor +public class StoreOnboardingService { + + private final StoreMapper storeMapper; + private final UserMapper userMapper; + private final ServiceTypeMapper serviceTypeMapper; + private final AuditLogService auditLogService; + + public Map getStatus(Long storeId) { + Store store = storeMapper.findByIdAndDeletedFalse(storeId).orElse(null); + if (store == null) { + throw new FlowException(404, "STORE_NOT_FOUND", "门店不存在"); + } + return buildStatus(store); + } + + @Transactional + public Map complete(Long storeId, Long actorUserId, String actorRole) { + Store store = storeMapper.findByIdAndDeletedFalseForUpdate(storeId).orElse(null); + if (store == null) { + throw new FlowException(404, "STORE_NOT_FOUND", "门店不存在"); + } + Map status = buildStatus(store); + @SuppressWarnings("unchecked") + List missingSteps = (List) status.get("missingRequiredSteps"); + if (!missingSteps.isEmpty()) { + throw new FlowException( + 409, + "ONBOARDING_NOT_READY", + "还有必填开通项未完成:" + String.join("、", missingSteps) + ); + } + boolean alreadyCompleted = "completed".equals(store.getOnboardingStatus()); + if (!alreadyCompleted) { + store.setOnboardingStatus("completed"); + store.setOnboardingCompletedAt(LocalDateTime.now()); + store.setOnboardingCompletedByUserId(actorUserId); + store.setUpdateTime(LocalDateTime.now()); + store = storeMapper.save(store); + auditLogService.record( + storeId, actorUserId, actorRole, "store_onboarding_completed", "store", storeId.toString(), + "success", Map.of("requiredStepCount", 3) + ); + } + Map result = new LinkedHashMap<>(buildStatus(store)); + result.put("alreadyCompleted", alreadyCompleted); + return result; + } + + private Map buildStatus(Store store) { + boolean profileComplete = notBlank(store.getName()) && notBlank(store.getPhone()) && notBlank(store.getAddress()); + boolean bookingComplete = store.getBookingDayStart() != null + && store.getBookingLastSlotStart() != null + && !store.getBookingLastSlotStart().isBefore(store.getBookingDayStart()) + && store.getBookingCapacity() != null + && store.getBookingCapacity() >= 1 + && store.getBookingCapacity() <= 10; + List serviceTypes = serviceTypeMapper.findByStoreIdOrStoreIdIsNull(store.getId()); + boolean serviceCatalogComplete = serviceTypes.stream().anyMatch(s -> s.getName() != null && !s.getName().isBlank()); + List members = userMapper.findByStoreIdAndDeletedFalse(store.getId()); + long staffCount = members.stream().filter(u -> "staff".equals(u.getRole())).count(); + + List> steps = new ArrayList<>(); + steps.add(step("profile", "补全门店资料", true, profileComplete, "填写门店名称、电话和地址")); + steps.add(step("booking", "确认预约容量", true, bookingComplete, "确认营业号源范围与并发接待数")); + steps.add(step("services", "确认服务项目", true, serviceCatalogComplete, "至少保留一个可预约服务项目")); + steps.add(step("team", "邀请员工", false, staffCount > 0, "单人门店可稍后完成")); + + List missing = new ArrayList<>(); + if (!profileComplete) missing.add("门店资料"); + if (!bookingComplete) missing.add("预约容量"); + if (!serviceCatalogComplete) missing.add("服务项目"); + + Map result = new LinkedHashMap<>(); + result.put("status", normalizeStatus(store.getOnboardingStatus())); + result.put("completedAt", store.getOnboardingCompletedAt()); + result.put("readyToComplete", missing.isEmpty()); + result.put("missingRequiredSteps", missing); + result.put("completedStepCount", steps.stream().filter(row -> Boolean.TRUE.equals(row.get("completed"))).count()); + result.put("totalStepCount", steps.size()); + result.put("staffCount", staffCount); + result.put("steps", steps); + return result; + } + + private static Map step(String id, String title, boolean required, boolean completed, String hint) { + Map row = new LinkedHashMap<>(); + row.put("id", id); + row.put("title", title); + row.put("required", required); + row.put("completed", completed); + row.put("hint", hint); + return row; + } + + private static String normalizeStatus(String status) { + if ("completed".equals(status) || "in_progress".equals(status) || "unknown".equals(status)) { + return status; + } + return "unknown"; + } + + private static boolean notBlank(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/src/main/java/com/petstore/service/StoreService.java b/src/main/java/com/petstore/service/StoreService.java index cc58dba..93a6d78 100644 --- a/src/main/java/com/petstore/service/StoreService.java +++ b/src/main/java/com/petstore/service/StoreService.java @@ -17,6 +17,13 @@ public class StoreService { public Store create(Store store) { store.setBookingCapacity(normalizeCapacity(store.getBookingCapacity())); + if (store.getBookingDayStart() == null) { + store.setBookingDayStart(StoreBookingWindow.DEFAULT_DAY_START); + } + if (store.getBookingLastSlotStart() == null) { + store.setBookingLastSlotStart(StoreBookingWindow.DEFAULT_LAST_SLOT_START); + } + store.setOnboardingStatus("in_progress"); store.setInviteCode(generateInviteCode()); store.setCreateTime(LocalDateTime.now()); store.setUpdateTime(LocalDateTime.now()); @@ -71,6 +78,12 @@ public class StoreService { existing.setLogo(incoming.getLogo()); } + // 门店一旦完成开通,核心联系方式不得再被清空,否则会出现“已开通但不可运营”的脏状态。 + if ("completed".equals(existing.getOnboardingStatus()) + && (isBlank(existing.getName()) || isBlank(existing.getPhone()) || isBlank(existing.getAddress()))) { + throw new IllegalArgumentException("已开通门店的名称、电话和地址不能为空"); + } + LocalTime start = existing.getBookingDayStart() != null ? StoreBookingWindow.snapHalfHour(existing.getBookingDayStart()) : StoreBookingWindow.DEFAULT_DAY_START; @@ -125,4 +138,8 @@ public class StoreService { } return capacity; } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } } diff --git a/src/main/java/com/petstore/service/UserService.java b/src/main/java/com/petstore/service/UserService.java index b07b223..86e8018 100644 --- a/src/main/java/com/petstore/service/UserService.java +++ b/src/main/java/com/petstore/service/UserService.java @@ -27,44 +27,6 @@ public class UserService { @Value("${app.demo.sms-universal-code:}") private String universalSmsCode; - public Map registerBoss(String storeName, String bossName, String phone, String password) { - if (userMapper.findByPhoneAndDeletedFalse(phone) != null) { - return Map.of("code", 400, "message", "手机号已注册"); - } - - Store store = new Store(); - store.setName(storeName); - store.setPhone(phone); - store.setOwnerId(0L); - store.setInviteCode(UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase()); - store.setCreateTime(LocalDateTime.now()); - store.setUpdateTime(LocalDateTime.now()); - store.setDeleted(false); - store = storeMapper.save(store); - - User boss = new User(); - boss.setUsername(phone); - boss.setName(bossName); - boss.setPhone(phone); - boss.setPassword(password); - boss.setStoreId(store.getId()); - boss.setRole("boss"); - boss.setCreateTime(LocalDateTime.now()); - boss.setUpdateTime(LocalDateTime.now()); - boss.setDeleted(false); - boss = userMapper.save(boss); - - store.setOwnerId(boss.getId()); - storeMapper.save(store); - - Map data = new HashMap<>(); - boss.setPassword(null); - data.put("user", boss); - data.put("store", store); - data.put("sessionToken", sessionTokenService.issue(boss.getId(), boss.getStoreId(), boss.getRole())); - return Map.of("code", 200, "message", "注册成功", "data", data); - } - public Map login(String phone, String code) { // 生产登录策略:只走微信授权(wx-phone-login);SMS 验证码登录仅 dev 模式可用。 // universalSmsCode 为空(production profile)= SMS 登录已关闭。 @@ -227,56 +189,6 @@ public class UserService { return userMapper.save(target); } - public Map registerStaff(String phone, String password, String name, String inviteCode) { - if (userMapper.findByPhoneAndDeletedFalse(phone) != null) { - return Map.of("code", 400, "message", "手机号已注册"); - } - Store store = storeMapper.findByInviteCodeAndDeletedFalse(inviteCode); - if (store == null) { - return Map.of("code", 400, "message", "邀请码无效"); - } - User staff = new User(); - staff.setUsername(phone); - staff.setPhone(phone); - staff.setPassword(password); - staff.setName(name); - staff.setStoreId(store.getId()); - staff.setRole("staff"); - staff.setCreateTime(LocalDateTime.now()); - staff.setUpdateTime(LocalDateTime.now()); - staff.setDeleted(false); - staff = userMapper.save(staff); - Map data = new HashMap<>(); - staff.setPassword(null); - data.put("user", staff); - data.put("store", store); - data.put("sessionToken", sessionTokenService.issue(staff.getId(), staff.getStoreId(), staff.getRole())); - return Map.of("code", 200, "message", "注册成功", "data", data); - } - - public Map createStaff(Long storeId, String name, String phone) { - if (storeId == null) { - return Map.of("code", 400, "message", "店铺ID不能为空"); - } - if (userMapper.findByPhoneAndDeletedFalse(phone) != null) { - return Map.of("code", 400, "message", "手机号已存在"); - } - String pwd = String.format("%06d", (int)(Math.random() * 999999)); - User staff = new User(); - staff.setUsername(phone); - staff.setName(name); - staff.setPhone(phone); - staff.setPassword(pwd); - staff.setStoreId(storeId); - staff.setRole("staff"); - staff.setCreateTime(LocalDateTime.now()); - staff.setUpdateTime(LocalDateTime.now()); - staff.setDeleted(false); - staff = userMapper.save(staff); - staff.setPassword(null); - return Map.of("code", 200, "message", "创建成功,初始密码:" + pwd, "data", staff); - } - public List getStaffList(Long storeId) { return userMapper.findByStoreIdAndDeletedFalse(storeId); } diff --git a/src/test/java/com/petstore/auth/AuthInterceptorActiveUserTest.java b/src/test/java/com/petstore/auth/AuthInterceptorActiveUserTest.java new file mode 100644 index 0000000..4b0d320 --- /dev/null +++ b/src/test/java/com/petstore/auth/AuthInterceptorActiveUserTest.java @@ -0,0 +1,106 @@ +package com.petstore.auth; + +import com.petstore.entity.User; +import com.petstore.mapper.StoreMapper; +import com.petstore.mapper.UserMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthInterceptorActiveUserTest { + + @Mock private SessionTokenService sessionTokenService; + @Mock private UserMapper userMapper; + @Mock private StoreMapper storeMapper; + + @AfterEach + void clear() { + CurrentUserContext.clear(); + } + + @Test + void deletedAccountImmediatelyInvalidatesSignedSession() throws Exception { + AuthInterceptor interceptor = new AuthInterceptor(sessionTokenService, userMapper, storeMapper); + MockHttpServletRequest request = protectedRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(sessionTokenService.verify("signed-token")) + .thenReturn(Optional.of(new CurrentUser(2L, 10L, "staff"))); + when(userMapper.findByIdAndDeletedFalse(2L)).thenReturn(Optional.empty()); + + assertFalse(interceptor.preHandle(request, response, new Object())); + assertEquals(401, response.getStatus()); + assertTrue(response.getContentAsString().contains("账号权限已变更")); + } + + @Test + void roleOrStoreDriftInvalidatesOldSession() throws Exception { + AuthInterceptor interceptor = new AuthInterceptor(sessionTokenService, userMapper, storeMapper); + MockHttpServletRequest request = protectedRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(sessionTokenService.verify("signed-token")) + .thenReturn(Optional.of(new CurrentUser(2L, 10L, "staff"))); + User active = new User(); + active.setId(2L); + active.setStoreId(20L); + active.setRole("staff"); + when(userMapper.findByIdAndDeletedFalse(2L)).thenReturn(Optional.of(active)); + + assertFalse(interceptor.preHandle(request, response, new Object())); + assertEquals(401, response.getStatus()); + } + + @Test + void activeAccountWithMatchingScopeIsAccepted() throws Exception { + AuthInterceptor interceptor = new AuthInterceptor(sessionTokenService, userMapper, storeMapper); + MockHttpServletRequest request = protectedRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + CurrentUser current = new CurrentUser(2L, 10L, "staff"); + when(sessionTokenService.verify("signed-token")).thenReturn(Optional.of(current)); + User active = new User(); + active.setId(2L); + active.setStoreId(10L); + active.setRole("staff"); + when(userMapper.findByIdAndDeletedFalse(2L)).thenReturn(Optional.of(active)); + when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(new com.petstore.entity.Store())); + + assertTrue(interceptor.preHandle(request, response, new Object())); + assertEquals(current, CurrentUserContext.require()); + } + + @Test + void deletedStoreImmediatelyInvalidatesStoreUserSession() throws Exception { + AuthInterceptor interceptor = new AuthInterceptor(sessionTokenService, userMapper, storeMapper); + MockHttpServletRequest request = protectedRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + CurrentUser current = new CurrentUser(2L, 10L, "staff"); + when(sessionTokenService.verify("signed-token")).thenReturn(Optional.of(current)); + User active = new User(); + active.setId(2L); + active.setStoreId(10L); + active.setRole("staff"); + when(userMapper.findByIdAndDeletedFalse(2L)).thenReturn(Optional.of(active)); + when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.empty()); + + assertFalse(interceptor.preHandle(request, response, new Object())); + assertEquals(401, response.getStatus()); + assertTrue(response.getContentAsString().contains("门店已停用")); + } + + private static MockHttpServletRequest protectedRequest() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/user/info"); + request.addHeader("Authorization", "Bearer signed-token"); + return request; + } +} diff --git a/src/test/java/com/petstore/controller/AdminStoreControllerTest.java b/src/test/java/com/petstore/controller/AdminStoreControllerTest.java index 1b1eef7..d26bf32 100644 --- a/src/test/java/com/petstore/controller/AdminStoreControllerTest.java +++ b/src/test/java/com/petstore/controller/AdminStoreControllerTest.java @@ -42,12 +42,13 @@ class AdminStoreControllerTest { } @Test - void staffGetsInviteCode() { + void staffGetsStoreWithoutLegacyInviteCode() { CurrentUserContext.set(new CurrentUser(2L, 10L, "staff")); Store store = new Store(); store.setId(10L); store.setName("测试店"); store.setInviteCode("ABCD1234"); + store.setOnboardingStatus("in_progress"); store.setBookingDayStart(LocalTime.of(9, 0)); store.setBookingLastSlotStart(LocalTime.of(21, 30)); when(storeService.findById(10L)).thenReturn(store); @@ -56,7 +57,8 @@ class AdminStoreControllerTest { assertEquals(200, result.get("code")); @SuppressWarnings("unchecked") Map data = (Map) result.get("data"); - assertEquals("ABCD1234", data.get("inviteCode")); + assertTrue(!data.containsKey("inviteCode")); + assertEquals("in_progress", data.get("onboardingStatus")); assertTrue(data.containsKey("bookingDayStart")); } } diff --git a/src/test/java/com/petstore/controller/StoreControllerTest.java b/src/test/java/com/petstore/controller/StoreControllerTest.java index 4cf124f..0572925 100644 --- a/src/test/java/com/petstore/controller/StoreControllerTest.java +++ b/src/test/java/com/petstore/controller/StoreControllerTest.java @@ -4,6 +4,7 @@ import com.petstore.auth.CurrentUser; import com.petstore.auth.CurrentUserContext; import com.petstore.entity.Store; import com.petstore.service.StoreService; +import com.petstore.service.AuditLogService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,6 +31,7 @@ import static org.mockito.Mockito.*; class StoreControllerTest { @Mock private StoreService storeService; + @Mock private AuditLogService auditLogService; @InjectMocks private StoreController controller; @@ -108,14 +110,12 @@ class StoreControllerTest { } @Test - void inviteCodeCrossStoreForbidden() { + void legacyInviteCodeDisabledEvenForCrossStoreCode() { CurrentUserContext.set(new CurrentUser(1L, 10L, "boss")); - Store other = new Store(); - other.setId(20L); // 他店 - when(storeService.findByInviteCode("ABC12345")).thenReturn(other); Map result = controller.getByInviteCode("ABC12345"); - assertEquals(403, result.get("code")); - assertEquals("FORBIDDEN", result.get("bizCode")); + assertEquals(410, result.get("code")); + assertEquals("LEGACY_INVITE_DISABLED", result.get("bizCode")); + verifyNoInteractions(storeService); } @Test @@ -127,13 +127,12 @@ class StoreControllerTest { } @Test - void inviteCodeSameStoreOk() { + void legacyInviteCodeDisabledForOwnStoreToo() { CurrentUserContext.set(new CurrentUser(1L, 10L, "boss")); - Store own = new Store(); - own.setId(10L); - when(storeService.findByInviteCode("ABC12345")).thenReturn(own); Map result = controller.getByInviteCode("ABC12345"); - assertEquals(200, result.get("code")); + assertEquals(410, result.get("code")); + assertEquals("LEGACY_INVITE_DISABLED", result.get("bizCode")); + verifyNoInteractions(storeService); } @SuppressWarnings("unchecked") diff --git a/src/test/java/com/petstore/mapper/StoreOnboardingMigrationTest.java b/src/test/java/com/petstore/mapper/StoreOnboardingMigrationTest.java new file mode 100644 index 0000000..d7b89ee --- /dev/null +++ b/src/test/java/com/petstore/mapper/StoreOnboardingMigrationTest.java @@ -0,0 +1,24 @@ +package com.petstore.mapper; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StoreOnboardingMigrationTest { + + @Test + void migrationKeepsRawTokenOutOfDatabaseAndDefinesAllInvariants() throws Exception { + String sql = Files.readString(Path.of("db/migrations/20260802_create_store_onboarding.sql")); + + assertTrue(sql.contains("token_hash VARCHAR(64) NOT NULL")); + assertTrue(sql.contains("UPDATE t_store\nSET onboarding_status = 'unknown'")); + assertTrue(sql.contains("t_staff_invitation")); + assertTrue(sql.contains("t_audit_log")); + assertTrue(sql.contains("invalid_store_onboarding_status_count")); + assertTrue(sql.contains("inconsistent_staff_invitation_acceptance_count")); + assertTrue(sql.contains("inconsistent_staff_invitation_revocation_count")); + } +} diff --git a/src/test/java/com/petstore/service/AuditLogServiceTest.java b/src/test/java/com/petstore/service/AuditLogServiceTest.java new file mode 100644 index 0000000..cea6cda --- /dev/null +++ b/src/test/java/com/petstore/service/AuditLogServiceTest.java @@ -0,0 +1,58 @@ +package com.petstore.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.petstore.entity.AuditLog; +import com.petstore.mapper.AuditLogMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuditLogServiceTest { + + @Mock private AuditLogMapper auditLogMapper; + + @Test + void persistsOnlyWhitelistedScalarMetadata() throws Exception { + when(auditLogMapper.save(any(AuditLog.class))).thenAnswer(invocation -> invocation.getArgument(0)); + AuditLogService service = new AuditLogService(auditLogMapper, new ObjectMapper()); + + AuditLog saved = service.record( + 10L, 1L, "BOSS", "STAFF_INVITATION_CREATED", "staff_invitation", "public-id", + "SUCCESS", Map.of("validDays", 7, "replaced", false) + ); + + assertEquals("staff_invitation_created", saved.getAction()); + assertEquals("boss", saved.getActorRole()); + assertEquals( + Map.of("validDays", 7, "replaced", false), + new ObjectMapper().readValue(saved.getMetadataJson(), Map.class) + ); + verify(auditLogMapper).save(saved); + } + + @Test + void rejectsCredentialOrPiiMetadata() { + AuditLogService service = new AuditLogService(auditLogMapper, new ObjectMapper()); + + assertThrows(IllegalArgumentException.class, () -> service.record( + 10L, 1L, "boss", "test", "store", "10", "success", + Map.of("inviteToken", "secret") + )); + assertThrows(IllegalArgumentException.class, () -> service.record( + 10L, 1L, "boss", "test", "store", "10", "success", + Map.of("phone", "13800138000") + )); + verifyNoInteractions(auditLogMapper); + } +} diff --git a/src/test/java/com/petstore/service/MerchantOnboardingServiceTest.java b/src/test/java/com/petstore/service/MerchantOnboardingServiceTest.java new file mode 100644 index 0000000..5050f5d --- /dev/null +++ b/src/test/java/com/petstore/service/MerchantOnboardingServiceTest.java @@ -0,0 +1,96 @@ +package com.petstore.service; + +import com.petstore.auth.SessionTokenService; +import com.petstore.entity.Store; +import com.petstore.entity.User; +import com.petstore.mapper.StoreMapper; +import com.petstore.mapper.UserMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MerchantOnboardingServiceTest { + + @Mock private UserMapper userMapper; + @Mock private StoreMapper storeMapper; + @Mock private SessionTokenService sessionTokenService; + @Mock private BusinessEventService businessEventService; + @Mock private AuditLogService auditLogService; + + private MerchantOnboardingService service; + + @BeforeEach + void setUp() { + service = new MerchantOnboardingService( + userMapper, storeMapper, sessionTokenService, businessEventService, auditLogService + ); + } + + @Test + void verifiedPhoneCreatesBossWithoutPasswordCredentialAndWritesFacts() { + when(userMapper.findByPhoneAndDeletedFalse("13800138000")).thenReturn(null); + when(userMapper.findByWechatOpenidAndDeletedFalse("openid-x")).thenReturn(Optional.empty()); + when(userMapper.findByWechatUnionidAndDeletedFalse("unionid-x")).thenReturn(Optional.empty()); + when(storeMapper.save(any(Store.class))).thenAnswer(invocation -> { + Store store = invocation.getArgument(0); + if (store.getId() == null) store.setId(10L); + return store; + }); + when(userMapper.save(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + user.setId(1L); + return user; + }); + when(sessionTokenService.issue(1L, 10L, "boss")).thenReturn("session"); + + Map result = service.registerVerifiedBoss( + " 测试宠物店 ", " 店长 ", "13800138000", "openid-x", "unionid-x" + ); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userMapper).save(userCaptor.capture()); + User boss = userCaptor.getValue(); + assertEquals("boss", boss.getRole()); + assertEquals("wx_no_password", boss.getPassword()); + assertFalse(result.toString().contains("wx_no_password")); + assertEquals("session", result.get("sessionToken")); + + ArgumentCaptor storeCaptor = ArgumentCaptor.forClass(Store.class); + verify(businessEventService).recordStoreRegistered(storeCaptor.capture(), eq(1L)); + assertEquals("测试宠物店", storeCaptor.getValue().getName()); + verify(auditLogService).record( + eq(10L), eq(1L), eq("boss"), eq("store_registered"), + eq("store"), eq("10"), eq("success"), any(Map.class) + ); + } + + @Test + void duplicatePhoneStopsBeforeCreatingStore() { + User existing = new User(); + when(userMapper.findByPhoneAndDeletedFalse("13800138000")).thenReturn(existing); + + FlowException error = assertThrows(FlowException.class, () -> service.registerVerifiedBoss( + "测试宠物店", "店长", "13800138000", "openid-x", null + )); + + assertEquals("PHONE_ALREADY_REGISTERED", error.bizCode()); + verify(storeMapper, never()).save(any(Store.class)); + verify(userMapper, never()).save(any(User.class)); + } +} diff --git a/src/test/java/com/petstore/service/StaffInvitationServiceTest.java b/src/test/java/com/petstore/service/StaffInvitationServiceTest.java new file mode 100644 index 0000000..371d5e7 --- /dev/null +++ b/src/test/java/com/petstore/service/StaffInvitationServiceTest.java @@ -0,0 +1,170 @@ +package com.petstore.service; + +import com.petstore.auth.SessionTokenService; +import com.petstore.entity.StaffInvitation; +import com.petstore.entity.Store; +import com.petstore.entity.User; +import com.petstore.mapper.StaffInvitationMapper; +import com.petstore.mapper.StoreMapper; +import com.petstore.mapper.UserMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StaffInvitationServiceTest { + + @Mock private StaffInvitationMapper invitationMapper; + @Mock private StoreMapper storeMapper; + @Mock private UserMapper userMapper; + @Mock private SessionTokenService sessionTokenService; + @Mock private AuditLogService auditLogService; + + private StaffInvitationService service; + + @BeforeEach + void setUp() { + service = new StaffInvitationService( + invitationMapper, storeMapper, userMapper, sessionTokenService, auditLogService + ); + } + + @Test + void createStoresHashAndReturnsRawTokenOnlyOnce() { + Store store = store(); + when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(store)); + when(userMapper.findByPhoneAndDeletedFalse("13800138000")).thenReturn(null); + when(invitationMapper.findByStoreIdAndInvitedPhoneAndStatus(10L, "13800138000", "pending")) + .thenReturn(java.util.List.of()); + when(invitationMapper.save(any(StaffInvitation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Map result = service.create( + 10L, 1L, "boss", "小李", "13800138000", 7 + ); + + String rawToken = result.get("inviteToken").toString(); + ArgumentCaptor captor = ArgumentCaptor.forClass(StaffInvitation.class); + verify(invitationMapper).save(captor.capture()); + StaffInvitation persisted = captor.getValue(); + assertEquals(43, rawToken.length()); + assertEquals(64, persisted.getTokenHash().length()); + assertNotEquals(rawToken, persisted.getTokenHash()); + assertEquals(StaffInvitationService.hashToken(rawToken), persisted.getTokenHash()); + assertEquals("138****8000", result.get("maskedPhone")); + assertFalse(result.toString().contains("13800138000")); + assertTrue(result.get("invitePath").toString().contains(rawToken)); + } + + @Test + void acceptRejectsMismatchedVerifiedPhoneWithoutCreatingAccount() { + StaffInvitation invitation = pendingInvitation("token"); + when(invitationMapper.findByTokenHashForUpdate(StaffInvitationService.hashToken("token"))) + .thenReturn(Optional.of(invitation)); + + FlowException error = assertThrows(FlowException.class, () -> service.acceptVerified( + "token", "13900139000", null, null + )); + + assertEquals("INVITATION_PHONE_MISMATCH", error.bizCode()); + verify(userMapper, never()).save(any(User.class)); + } + + @Test + void acceptCreatesStaffAndConsumesInvitation() { + StaffInvitation invitation = pendingInvitation("token"); + Store store = store(); + when(invitationMapper.findByTokenHashForUpdate(StaffInvitationService.hashToken("token"))) + .thenReturn(Optional.of(invitation)); + when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(store)); + when(userMapper.findByPhoneAndDeletedFalse("13800138000")).thenReturn(null); + when(userMapper.findByWechatOpenidAndDeletedFalse("openid-x")).thenReturn(Optional.empty()); + when(userMapper.save(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + user.setId(22L); + return user; + }); + when(invitationMapper.save(any(StaffInvitation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(sessionTokenService.issue(22L, 10L, "staff")).thenReturn("session"); + + Map result = service.acceptVerified( + "token", "13800138000", "openid-x", null + ); + + assertEquals("accepted", invitation.getStatus()); + assertEquals(22L, invitation.getAcceptedByUserId()); + assertEquals("session", result.get("sessionToken")); + @SuppressWarnings("unchecked") + Map user = (Map) result.get("user"); + assertEquals("staff", user.get("role")); + verify(auditLogService).record( + eq(10L), eq(22L), eq("staff"), eq("staff_invitation_accepted"), + eq("staff_invitation"), eq("invite-id"), eq("success"), any(Map.class) + ); + } + + @Test + void acceptedInvitationOnlyRestoresSameActiveStaffSession() { + StaffInvitation invitation = pendingInvitation("token"); + invitation.setStatus("accepted"); + invitation.setAcceptedByUserId(22L); + invitation.setAcceptedAt(LocalDateTime.now()); + User staff = new User(); + staff.setId(22L); + staff.setPhone("13800138000"); + staff.setStoreId(10L); + staff.setRole("staff"); + when(invitationMapper.findByTokenHashForUpdate(StaffInvitationService.hashToken("token"))) + .thenReturn(Optional.of(invitation)); + when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(store())); + when(userMapper.findByIdAndDeletedFalse(22L)).thenReturn(Optional.of(staff)); + when(sessionTokenService.issue(22L, 10L, "staff")).thenReturn("session"); + + Map result = service.acceptVerified( + "token", "13800138000", "ignored-openid", null + ); + + assertEquals(true, result.get("alreadyAccepted")); + assertEquals("session", result.get("sessionToken")); + verify(userMapper, never()).save(any(User.class)); + verify(invitationMapper, never()).save(any(StaffInvitation.class)); + } + + private static Store store() { + Store store = new Store(); + store.setId(10L); + store.setName("测试宠物店"); + return store; + } + + private static StaffInvitation pendingInvitation(String rawToken) { + StaffInvitation invitation = new StaffInvitation(); + invitation.setInvitationId("invite-id"); + invitation.setTokenHash(StaffInvitationService.hashToken(rawToken)); + invitation.setStoreId(10L); + invitation.setInvitedName("小李"); + invitation.setInvitedPhone("13800138000"); + invitation.setStatus("pending"); + invitation.setExpiresAt(LocalDateTime.now().plusDays(1)); + invitation.setCreateTime(LocalDateTime.now()); + invitation.setUpdateTime(LocalDateTime.now()); + return invitation; + } +} diff --git a/src/test/java/com/petstore/service/StoreOnboardingServiceTest.java b/src/test/java/com/petstore/service/StoreOnboardingServiceTest.java new file mode 100644 index 0000000..3d5cd04 --- /dev/null +++ b/src/test/java/com/petstore/service/StoreOnboardingServiceTest.java @@ -0,0 +1,116 @@ +package com.petstore.service; + +import com.petstore.entity.ServiceType; +import com.petstore.entity.Store; +import com.petstore.entity.User; +import com.petstore.mapper.ServiceTypeMapper; +import com.petstore.mapper.StoreMapper; +import com.petstore.mapper.UserMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StoreOnboardingServiceTest { + + @Mock private StoreMapper storeMapper; + @Mock private UserMapper userMapper; + @Mock private ServiceTypeMapper serviceTypeMapper; + @Mock private AuditLogService auditLogService; + + private StoreOnboardingService service; + + @BeforeEach + void setUp() { + service = new StoreOnboardingService(storeMapper, userMapper, serviceTypeMapper, auditLogService); + } + + @Test + void addressIsRequiredButStaffInvitationIsOptional() { + Store store = readyStore(); + store.setAddress(null); + when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(store)); + when(serviceTypeMapper.findByStoreIdOrStoreIdIsNull(10L)).thenReturn(List.of(serviceType())); + when(userMapper.findByStoreIdAndDeletedFalse(10L)).thenReturn(List.of(boss())); + + Map status = service.getStatus(10L); + + assertFalse((Boolean) status.get("readyToComplete")); + assertEquals(List.of("门店资料"), status.get("missingRequiredSteps")); + } + + @Test + void bossCanCompleteReadySoloStoreOnce() { + Store store = readyStore(); + when(storeMapper.findByIdAndDeletedFalseForUpdate(10L)).thenReturn(Optional.of(store)); + when(serviceTypeMapper.findByStoreIdOrStoreIdIsNull(10L)).thenReturn(List.of(serviceType())); + when(userMapper.findByStoreIdAndDeletedFalse(10L)).thenReturn(List.of(boss())); + when(storeMapper.save(any(Store.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Map status = service.complete(10L, 1L, "boss"); + + assertEquals("completed", status.get("status")); + assertTrue((Boolean) status.get("readyToComplete")); + assertEquals(false, status.get("alreadyCompleted")); + verify(auditLogService).record( + eq(10L), eq(1L), eq("boss"), eq("store_onboarding_completed"), + eq("store"), eq("10"), eq("success"), any(Map.class) + ); + } + + @Test + void cannotCompleteWhenRequiredStepMissing() { + Store store = readyStore(); + store.setAddress(""); + when(storeMapper.findByIdAndDeletedFalseForUpdate(10L)).thenReturn(Optional.of(store)); + when(serviceTypeMapper.findByStoreIdOrStoreIdIsNull(10L)).thenReturn(List.of(serviceType())); + when(userMapper.findByStoreIdAndDeletedFalse(10L)).thenReturn(List.of(boss())); + + FlowException error = assertThrows(FlowException.class, () -> service.complete(10L, 1L, "boss")); + + assertEquals("ONBOARDING_NOT_READY", error.bizCode()); + verify(storeMapper, never()).save(any(Store.class)); + } + + private static Store readyStore() { + Store store = new Store(); + store.setId(10L); + store.setName("测试宠物店"); + store.setPhone("13800138000"); + store.setAddress("测试路 1 号"); + store.setBookingDayStart(LocalTime.of(9, 0)); + store.setBookingLastSlotStart(LocalTime.of(21, 30)); + store.setBookingCapacity(1); + store.setOnboardingStatus("in_progress"); + return store; + } + + private static ServiceType serviceType() { + ServiceType serviceType = new ServiceType(); + serviceType.setName("洗护"); + return serviceType; + } + + private static User boss() { + User user = new User(); + user.setRole("boss"); + return user; + } +} diff --git a/src/test/java/com/petstore/service/StoreServiceTest.java b/src/test/java/com/petstore/service/StoreServiceTest.java new file mode 100644 index 0000000..9d2f2b4 --- /dev/null +++ b/src/test/java/com/petstore/service/StoreServiceTest.java @@ -0,0 +1,78 @@ +package com.petstore.service; + +import com.petstore.entity.Store; +import com.petstore.mapper.StoreMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StoreServiceTest { + + @Mock private StoreMapper storeMapper; + + private StoreService service; + + @BeforeEach + void setUp() { + service = new StoreService(storeMapper); + } + + @Test + void completedStoreCannotClearRequiredProfile() { + Store existing = completedStore(); + Store incoming = new Store(); + incoming.setId(10L); + incoming.setAddress(" "); + when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(existing)); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> service.update(incoming) + ); + + assertEquals("已开通门店的名称、电话和地址不能为空", error.getMessage()); + verify(storeMapper, never()).save(any(Store.class)); + } + + @Test + void completedStoreCanUpdateRequiredProfileWithNonBlankValue() { + Store existing = completedStore(); + Store incoming = new Store(); + incoming.setId(10L); + incoming.setAddress("新地址 2 号"); + when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(existing)); + when(storeMapper.save(any(Store.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Store updated = service.update(incoming); + + assertEquals("新地址 2 号", updated.getAddress()); + verify(storeMapper).save(existing); + } + + private static Store completedStore() { + Store store = new Store(); + store.setId(10L); + store.setName("测试宠物店"); + store.setPhone("13800138000"); + store.setAddress("测试路 1 号"); + store.setBookingDayStart(LocalTime.of(9, 0)); + store.setBookingLastSlotStart(LocalTime.of(21, 30)); + store.setBookingCapacity(1); + store.setOnboardingStatus("completed"); + store.setDeleted(false); + return store; + } +}