feat: add secure store onboarding and staff invites

This commit is contained in:
malei 2026-08-02 01:02:10 +08:00
parent 1887864d38
commit 207115e18f
34 changed files with 2078 additions and 140 deletions

View File

@ -107,6 +107,12 @@ curl http://localhost:8080/api/store/list
```
脚本把历史报告统一标记为 `unknown`,迁移后的新报告默认 `unsent`;不得推测历史发送事实。末尾三项状态与回执一致性验证必须全部为 0。
9. **门店开通、员工邀请与操作审计**:报告发送状态验证完成后执行:
```bash
mysql -h <host> -u <user> -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://<api-domain> 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://<api-domain> 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 <token>`
- **跨店/跨用户边界**:所有 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邀请绑定微信核验手机号、限时、一次使用、可撤销。
- **操作审计**:门店开通/设置和员工权限操作写入不可变低敏 AuditLogmetadata 禁止 PII、凭证和原始请求体。
## 参与贡献

View File

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

View File

@ -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 后续任务;接入前不得把本目录误当成自动执行目录。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String, Object> registerBoss(@RequestBody(required = false) Map<String, String> 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<String, Object> 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<String, Object> error(int code, String bizCode, String message) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("code", code);
result.put("bizCode", bizCode);
result.put("message", message == null || message.isBlank() ? "操作失败" : message);
return result;
}
}

View File

@ -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<String, Object> create(@RequestBody Map<String, Object> params) {
CurrentUser user = CurrentUserContext.require();
if (!user.isBoss() || user.storeId() == null) {
return error(403, "FORBIDDEN", "仅老板可邀请员工");
}
try {
Integer validDays = integerOrNull(params.get("validDays"));
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> accept(@RequestBody(required = false) Map<String, String> 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<String, Object> 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<String, Object> error(int code, String bizCode, String message) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("code", code);
result.put("bizCode", bizCode);
result.put("message", message == null || message.isBlank() ? "操作失败" : message);
return result;
}
}

View File

@ -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<String, Object> register(@RequestBody Store store) {
Store created = storeService.create(store);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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"
);
}
}

View File

@ -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<String, Object> registerBoss(@RequestBody Map<String, String> 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<String, Object> registerStaff(@RequestBody Map<String, String> 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。 */

View File

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

View File

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

View File

@ -54,6 +54,16 @@ public class Store {
@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;

View File

@ -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<AuditLog, Long> {
}

View File

@ -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<StaffInvitation, Long> {
Optional<StaffInvitation> findFirstByTokenHash(String tokenHash);
List<StaffInvitation> findByStoreIdOrderByCreateTimeDesc(Long storeId);
List<StaffInvitation> findByStoreIdAndInvitedPhoneAndStatus(Long storeId, String invitedPhone, String status);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT i FROM StaffInvitation i WHERE i.tokenHash = :tokenHash")
Optional<StaffInvitation> findByTokenHashForUpdate(@Param("tokenHash") String tokenHash);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT i FROM StaffInvitation i WHERE i.invitationId = :invitationId AND i.storeId = :storeId")
Optional<StaffInvitation> findByInvitationIdAndStoreIdForUpdate(
@Param("invitationId") String invitationId,
@Param("storeId") Long storeId
);
}

View File

@ -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<String> 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<String, Object> 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<String, Object> metadata) {
Map<String, Object> safe = new LinkedHashMap<>();
if (metadata != null) {
for (Map.Entry<String, Object> 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);
}
}

View File

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

View File

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

View File

@ -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<String, Object> 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<String, Object> 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<String, Object> storeView = new LinkedHashMap<>();
storeView.put("id", store.getId());
storeView.put("name", store.getName());
storeView.put("phone", store.getPhone());
storeView.put("onboardingStatus", store.getOnboardingStatus());
Map<String, Object> 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();
}
}

View File

@ -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<String, Object> 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", "邀请有效期须为 130 天");
}
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<String, Object> result = toView(invitation, store.getName());
result.put("inviteToken", rawToken);
result.put("invitePath", "/pages/login/Login?staffInvite=" + rawToken);
return result;
}
@Transactional
public List<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> result = toView(invitation, store.getName());
result.put("alreadyRevoked", alreadyRevoked);
return result;
}
@Transactional
public Map<String, Object> 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<String, Object> loginView(User user, Store store, boolean alreadyAccepted) {
Map<String, Object> 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<String, Object> storeView = new LinkedHashMap<>();
storeView.put("id", store.getId());
storeView.put("name", store.getName());
storeView.put("phone", store.getPhone());
Map<String, Object> 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<String, Object> toView(StaffInvitation invitation, String storeName) {
Map<String, Object> 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();
}
}

View File

@ -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<String, Object> 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<String, Object> 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<String, Object> status = buildStatus(store);
@SuppressWarnings("unchecked")
List<String> missingSteps = (List<String>) 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<String, Object> result = new LinkedHashMap<>(buildStatus(store));
result.put("alreadyCompleted", alreadyCompleted);
return result;
}
private Map<String, Object> 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<ServiceType> serviceTypes = serviceTypeMapper.findByStoreIdOrStoreIdIsNull(store.getId());
boolean serviceCatalogComplete = serviceTypes.stream().anyMatch(s -> s.getName() != null && !s.getName().isBlank());
List<User> members = userMapper.findByStoreIdAndDeletedFalse(store.getId());
long staffCount = members.stream().filter(u -> "staff".equals(u.getRole())).count();
List<Map<String, Object>> 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<String> missing = new ArrayList<>();
if (!profileComplete) missing.add("门店资料");
if (!bookingComplete) missing.add("预约容量");
if (!serviceCatalogComplete) missing.add("服务项目");
Map<String, Object> 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<String, Object> step(String id, String title, boolean required, boolean completed, String hint) {
Map<String, Object> 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();
}
}

View File

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

View File

@ -27,44 +27,6 @@ public class UserService {
@Value("${app.demo.sms-universal-code:}")
private String universalSmsCode;
public Map<String, Object> 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<String, Object> 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<String, Object> login(String phone, String code) {
// 生产登录策略只走微信授权wx-phone-loginSMS 验证码登录仅 dev 模式可用
// universalSmsCode 为空production profile= SMS 登录已关闭
@ -227,56 +189,6 @@ public class UserService {
return userMapper.save(target);
}
public Map<String, Object> 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<String, Object> 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<String, Object> 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<User> getStaffList(Long storeId) {
return userMapper.findByStoreIdAndDeletedFalse(storeId);
}

View File

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

View File

@ -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<String, Object> data = (Map<String, Object>) result.get("data");
assertEquals("ABCD1234", data.get("inviteCode"));
assertTrue(!data.containsKey("inviteCode"));
assertEquals("in_progress", data.get("onboardingStatus"));
assertTrue(data.containsKey("bookingDayStart"));
}
}

View File

@ -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<String, Object> 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<String, Object> 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")

View File

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

View File

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

View File

@ -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<String, Object> result = service.registerVerifiedBoss(
" 测试宠物店 ", " 店长 ", "13800138000", "openid-x", "unionid-x"
);
ArgumentCaptor<User> 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<Store> 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));
}
}

View File

@ -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<String, Object> result = service.create(
10L, 1L, "boss", "小李", "13800138000", 7
);
String rawToken = result.get("inviteToken").toString();
ArgumentCaptor<StaffInvitation> 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<String, Object> result = service.acceptVerified(
"token", "13800138000", "openid-x", null
);
assertEquals("accepted", invitation.getStatus());
assertEquals(22L, invitation.getAcceptedByUserId());
assertEquals("session", result.get("sessionToken"));
@SuppressWarnings("unchecked")
Map<String, Object> user = (Map<String, Object>) 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<String, Object> 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;
}
}

View File

@ -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<String, Object> 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<String, Object> 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;
}
}

View File

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