【微服务】Staffjoy 项目源码解析(五)—— Account 模块

一. 架构设计

在这里插入图片描述
省略了 Swagger (自动文档生成工具)配置类。

二. 代码分析

大工程开始了。

基本架构还是比较清晰的。
由上图可知,在之前的基本架构基础上,因为有了数据库,所以有了 Repo 层。
两个类都是继承了 JPA 接口的接口类。
类中的方法符合 JPA 接口的定义,不需写具体的 SQL 语句。
细节属于微服务核心思想外,暂不细说(还没研究)
大致就是只要方法名符合定义规则,能自动实现语句。

两个接口类,一个 AccountRepo 是负责查询 Account 信息
另一个 AccountSecretRepo 负责查询含有密码的登录使用的 AccountSecret 信息
展示一下方法,不复杂

// AccountRepo
    Account findAccountById(String id);

    Account findAccountByEmail(String email);

    Account findAccountByPhoneNumber(String phoneNumber);

    @Modifying(clearAutomatically = true)
    @Query("update Account account set account.email = :email, account.confirmedAndActive = true where account.id = :id")
    @Transactional
    int updateEmailAndActivateById(@Param("email") String email, @Param("id") String id);

// AccountSecretRepo
    AccountSecret findAccountSecretByEmail(String email);

    @Modifying(clearAutomatically = true)
    @Query("update AccountSecret accountSecret set accountSecret.passwordHash = :passwordHash where accountSecret.id = :id")
    @Transactional
    int updatePasswordHashById(@Param("passwordHash") String passwordHash, @Param("id") String id);

而本模块的 AppProps 中储存的是 与 intercomAccessToken 客户系统连接的密钥

本模块的 AppConfig 中的代码除了异步线程池,还有一个密码加密方法。

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 返回加密过的密码(加密类)
        return new BCryptPasswordEncoder();
    }

接着,是重头戏 AccountService 解析。
方法较多,挑个人认为较为重要的进行讲解。

首先,必然是要有账户创建方法的

    public AccountDto create(String name, String email, String phoneNumber) {
        if (StringUtils.hasText(email)) {
            // Check to see if account exists
            Account foundAccount = accountRepo.findAccountByEmail(email);
            if (foundAccount != null) {
                throw new ServiceException("A user with that email already exists. Try a password reset");
            }
        }
        if (StringUtils.hasText(phoneNumber)) {
            Account foundAccount = accountRepo.findAccountByPhoneNumber(phoneNumber);
            if (foundAccount != null) {
                throw new ServiceException("A user with that phonenumber already exists. Try a password reset");
            }
        }

        // Column name/email/phone_number cannot be null
        if (name == null) {
            name = "";
        }
        if (email == null) {
            email = "";
        }
        if (phoneNumber == null) {
            phoneNumber = "";
        }

        Account account = Account.builder()
                .email(email).name(name).phoneNumber(phoneNumber)
                .build();
        account.setPhotoUrl(Helper.generateGravatarUrl(account.getEmail()));
        account.setMemberSince(Instant.now());

        try {
            accountRepo.save(account);
        } catch (Exception ex) {
            String errMsg = "Could not create user account";
            serviceHelper.handleException(logger, ex, errMsg);
            throw new ServiceException(errMsg, ex);
        }

        serviceHelper.syncUserAsync(account.getId());

        if (StringUtils.hasText(email)) {
            // Email confirmation

            String emailName = name;
            if (StringUtils.isEmpty(emailName)) {
                emailName = "there";
            }

            String subject = "Activate your Staffjoy account";
            this.sendEmail(account.getId(), email, emailName, subject, AccountConstant.ACTIVATE_ACCOUNT_TMPL, true);
        }

        // todo - sms onboarding (if worker??)

        // 创建结构化日志的地方,以键值对的形式
        LogEntry auditLog = LogEntry.builder()
                .authorization(AuthContext.getAuthz())
                .currentUserId(AuthContext.getUserId())
                .targetType("account")
                .targetId(account.getId())
                .updatedContents(account.toString())
                .build();

        logger.info("created account", auditLog);

        AccountDto accountDto = this.convertToDto(account);
        return accountDto;
    }

代码量较多,但并不复杂
逻辑如下。
通过传入的 name,email,phone 三个参数,判断是否已有账户存在。
没有则创建,并存储
通过 serviceHelper.syncUserAsync(account.getId()) 将用户 id 同步到 Intercom 中取
记录结构化日志,返回 DTO(数据传输对象)

然后是账户更新方法

    public AccountDto update(AccountDto newAccountDto) {
        Account newAccount = this.convertToModel(newAccountDto);

        Account existingAccount = accountRepo.findAccountById(newAccount.getId());
        if (existingAccount == null) {
            throw new ServiceException(ResultCode.NOT_FOUND, String.format("User with id %s not found", newAccount.getId()));
        }
        entityManager.detach(existingAccount);

        if (!serviceHelper.isAlmostSameInstant(newAccount.getMemberSince(), existingAccount.getMemberSince())) {
            throw new ServiceException(ResultCode.REQ_REJECT, "You cannot modify the member_since date");
        }

        if (StringUtils.hasText(newAccount.getEmail()) && !newAccount.getEmail().equals(existingAccount.getEmail())) {
            Account foundAccount = accountRepo.findAccountByEmail(newAccount.getEmail());
            if (foundAccount != null) {
                throw new ServiceException(ResultCode.REQ_REJECT, "A user with that email already exists. Try a password reset");
            }
        }

        if (StringUtils.hasText(newAccount.getPhoneNumber()) && !newAccount.getPhoneNumber().equals(existingAccount.getPhoneNumber())) {
            Account foundAccount = accountRepo.findAccountByPhoneNumber(newAccount.getPhoneNumber());
            if (foundAccount != null) {
                throw new ServiceException(ResultCode.REQ_REJECT, "A user with that phonenumber already exists. Try a password reset");
            }
        }

        if (AuthConstant.AUTHORIZATION_AUTHENTICATED_USER.equals(AuthContext.getAuthz())) {
            if (!existingAccount.isConfirmedAndActive() && newAccount.isConfirmedAndActive()) {
                throw new ServiceException(ResultCode.REQ_REJECT, "You cannot activate this account");
            }
            if (existingAccount.isSupport() != newAccount.isSupport()) {
                throw new ServiceException(ResultCode.REQ_REJECT, "You cannot change the support parameter");
            }
            if (!existingAccount.getPhotoUrl().equals(newAccount.getPhotoUrl())) {
                throw new ServiceException(ResultCode.REQ_REJECT, "You cannot change the photo through this endpoint (see docs)");
            }
            // User can request email change - not do it :-)
            if (!existingAccount.getEmail().equals(newAccount.getEmail())) {
                this.requestEmailChange(newAccount.getId(), newAccount.getEmail());
                // revert
                newAccount.setEmail(existingAccount.getEmail());
            }
        }

        newAccount.setPhotoUrl(Helper.generateGravatarUrl(newAccount.getEmail()));

        try {
            accountRepo.save(newAccount);
        } catch (Exception ex) {
            String errMsg = "Could not update the user account";
            serviceHelper.handleException(logger, ex, errMsg);
            throw new ServiceException(errMsg, ex);
        }

        serviceHelper.syncUserAsync(newAccount.getId());

        LogEntry auditLog = LogEntry.builder()
                .authorization(AuthContext.getAuthz())
                .currentUserId(AuthContext.getUserId())
                .targetType("account")
                .targetId(newAccount.getId())
                .originalContents(existingAccount.toString())
                .updatedContents(newAccount.toString())
                .build();

        logger.info("updated account", auditLog);

        // If account is being activated, or if phone number is changed by current user - send text
        if (newAccount.isConfirmedAndActive() &&
                StringUtils.hasText(newAccount.getPhoneNumber()) &&
                !newAccount.getPhoneNumber().equals(existingAccount.getPhoneNumber())) {
            serviceHelper.sendSmsGreeting(newAccount.getId());
        }

        this.trackEventWithAuthCheck("account_updated");

        AccountDto accountDto = this.convertToDto(newAccount);
        return accountDto;
    }

传入的参数是更新后的账户信息。
账户的 id 不会被修改,所以可以根据 id 找到旧账户的信息
通过 entityManager.detach() 将持久化上下文中的旧账户信息清空
验证新账户信息符合要求后,存储,之后的流程与 create 相同。

最后一个提的方法是密码的更新。

    public void updatePassword(String userId, String password) {
        String pwHash = passwordEncoder.encode(password);

        int affected = accountSecretRepo.updatePasswordHashById(pwHash, userId);
        if (affected != 1) {
            throw new ServiceException(ResultCode.NOT_FOUND, "user with specified id not found");
        }

        LogEntry auditLog = LogEntry.builder()
                .authorization(AuthContext.getAuthz())
                .currentUserId(AuthContext.getUserId())
                .targetType("account")
                .targetId(userId)
                .build();

        logger.info("updated password", auditLog);

        this.trackEventWithAuthCheck("password_updated");
    }


更新方法流程如下
将输入的密码转化为加密后的版本,用在 AppConfig 中定义的 Bean 加密
然后更新数据库的数据
记录一下事件。

最后附上整个 AccountService 中的剩余关键方法和作用

getOrCreate:获得账户,账户不存在则调用 create 方法创建
getAccountByPhoneNumber:通过 电话 得到账户
list:查找所有账户信息,以列表形式返回
verifyPassword:验证密码
requestPasswordReset:要求密码重设,发送邮件
changeEmailAndActivateAccount:修改邮件地址并激活账户
trackEvent:监听并记录事件
syncUser:同步数据到 Intercom
convertToModel:DTO(数据传输对象) 和 DMO(数据模型对象)转换,通过封装好的 ModelMapper

然后是 ServiceHelper 类
主要方法有三

  1. 异步的同步数据到 Intercom 的 syncUserAsync 方法
    @Async(AppConfig.ASYNC_EXECUTOR_NAME)
    public void syncUserAsync(String userId) {
        // 在 DeBug 情况下才记录日志
        
        if (envConfig.isDebug()) {
            logger.debug("intercom disabled in dev & test environment");
            return;
        }

        Account account = accountRepo.findAccountById(userId);
        if (account == null) {
            throw new ServiceException(ResultCode.NOT_FOUND, String.format("User with id %s not found", userId));
        }
        if (StringUtils.isEmpty(account.getPhoneNumber()) && StringUtils.isEmpty(account.getEmail())) {
            logger.info(String.format("skipping sync for user %s because no email or phonenumber", account.getId()));
            return;
        }

        // use a map to de-dupe
        Map<String, CompanyDto> memberships = new HashMap<>();

        GetWorkerOfResponse workerOfResponse = null;
        try {
            workerOfResponse = companyClient.getWorkerOf(AuthConstant.AUTHORIZATION_ACCOUNT_SERVICE, userId);
        } catch(Exception ex) {
            String errMsg = "could not fetch workOfList";
            handleException(logger, ex, errMsg);
            throw new ServiceException(errMsg, ex);
        }
        if (!workerOfResponse.isSuccess()) {
            handleError(logger, workerOfResponse.getMessage());
            throw new ServiceException(workerOfResponse.getMessage());
        }
        WorkerOfList workerOfList = workerOfResponse.getWorkerOfList();

        boolean isWorker = workerOfList.getTeams().size() > 0;
        for(TeamDto teamDto : workerOfList.getTeams()) {
            GenericCompanyResponse genericCompanyResponse = null;
            try {
                genericCompanyResponse = companyClient.getCompany(AuthConstant.AUTHORIZATION_ACCOUNT_SERVICE, teamDto.getCompanyId());
            } catch (Exception ex) {
                String errMsg = "could not fetch companyDto from teamDto";
                handleException(logger, ex, errMsg);
                throw new ServiceException(errMsg, ex);
            }

            if (!genericCompanyResponse.isSuccess()) {
                handleError(logger, genericCompanyResponse.getMessage());
                throw new ServiceException(genericCompanyResponse.getMessage());
            }

            CompanyDto companyDto = genericCompanyResponse.getCompany();

            memberships.put(companyDto.getId(), companyDto);
        }

        GetAdminOfResponse getAdminOfResponse = null;
        try {
            getAdminOfResponse = companyClient.getAdminOf(AuthConstant.AUTHORIZATION_ACCOUNT_SERVICE, userId);
        } catch (Exception ex) {
            String errMsg = "could not fetch adminOfList";
            handleException(logger, ex, errMsg);
            throw new ServiceException(errMsg, ex);
        }
        if (!getAdminOfResponse.isSuccess()) {
            handleError(logger, getAdminOfResponse.getMessage());
            throw new ServiceException(getAdminOfResponse.getMessage());
        }
        AdminOfList adminOfList = getAdminOfResponse.getAdminOfList();

        boolean isAdmin = adminOfList.getCompanies().size() > 0;
        for(CompanyDto companyDto : adminOfList.getCompanies()) {
            memberships.put(companyDto.getId(), companyDto);
        }

        User user = new User();
        user.setUserId(account.getId());
        user.setEmail(account.getEmail());
        user.setName(account.getName());
        user.setSignedUpAt(account.getMemberSince().toEpochMilli());
        user.setAvatar(new Avatar().setImageURL(account.getPhotoUrl()));
        user.setLastRequestAt(Instant.now().toEpochMilli());

        user.addCustomAttribute(CustomAttribute.newBooleanAttribute("v2", true));
        user.addCustomAttribute(CustomAttribute.newStringAttribute("phonenumber", account.getPhoneNumber()));
        user.addCustomAttribute(CustomAttribute.newBooleanAttribute("confirmed_and_active", account.isConfirmedAndActive()));
        user.addCustomAttribute(CustomAttribute.newBooleanAttribute("is_worker", isWorker));
        user.addCustomAttribute(CustomAttribute.newBooleanAttribute("is_admin", isAdmin));
        user.addCustomAttribute(CustomAttribute.newBooleanAttribute("is_staffjoy_support", account.isSupport()));

        for(CompanyDto companyDto : memberships.values()) {
            user.addCompany(new io.intercom.api.Company().setCompanyID(companyDto.getId()).setName(companyDto.getName()));
        }

        this.syncUserWithIntercom(user, account.getId());
    }

    void syncUserWithIntercom(User user, String userId) {
        try {
            Map<String, String> params = Maps.newHashMap();
            params.put("user_id", userId);

            User existing = User.find(params);

            if (existing != null) {
                User.update(user);
            } else {
                User.create(user);
            }

            logger.debug("updated intercom");
        } catch (Exception ex) {
            String errMsg = "fail to create/update user on Intercom";
            handleException(logger, ex, errMsg);
            throw new ServiceException(errMsg, ex);
        }
    }

处理逻辑如下:
传入参数 userId
根据 id 找到 account
并根据用户 id 和 权限参数 调用 companyClient 找到对应的 worker 对象
这里实现了和 Company 模块的交互
再通过 worker 找到 team 和 company 嵌入一个 map(String,companyDto)
再得到管理员队列,也嵌入该 map
最后生成 user 对象(Intercom 封装好的)
同步到 Intercom

接着是 事件监听方法

    @Async(AppConfig.ASYNC_EXECUTOR_NAME)
    public void trackEventAsync(String userId, String eventName) {
        if (envConfig.isDebug()) {
            logger.debug("intercom disabled in dev & test environment");
            return;
        }

        Event event = new Event()
                .setUserID(userId)
                .setEventName("v2_" + eventName)
                .setCreatedAt(Instant.now().toEpochMilli());

        try {
            Event.create(event);
        } catch (Exception ex) {
            String errMsg = "fail to create event on Intercom";
            handleException(logger, ex, errMsg);
            throw new ServiceException(errMsg, ex);
        }

        logger.debug("updated intercom");
    }

老样子,生成一个封装对象 event 进行处理

最后是发送欢迎短信方法

    public void sendSmsGreeting(String userId) {
        BaseResponse baseResponse = null;
        try {
            GreetingRequest greetingRequest = GreetingRequest.builder().userId(userId).build();
            baseResponse = botClient.sendSmsGreeting(greetingRequest);
        } catch (Exception ex) {
            String errMsg = "could not send welcome sms";
            handleException(logger, ex, errMsg);
            throw new ServiceException(errMsg, ex);
        }
        if (!baseResponse.isSuccess()) {
            handleError(logger, baseResponse.getMessage());
            throw new ServiceException(baseResponse.getMessage());
        }
    }

没啥好讲的。

最后到了 Controller 层。重点在于各个方法不同的权限管理
都写在了 @Authorize 注解中
大部分是权限用户,www 服务,company 服务
少数有增减

比如 getAccount 方法,基本啥都能调

    @GetMapping(path = "/get")
    @Authorize(value = {
            AuthConstant.AUTHORIZATION_WWW_SERVICE,
            AuthConstant.AUTHORIZATION_ACCOUNT_SERVICE,
            AuthConstant.AUTHORIZATION_COMPANY_SERVICE,
            AuthConstant.AUTHORIZATION_WHOAMI_SERVICE,
            AuthConstant.AUTHORIZATION_BOT_SERVICE,
            AuthConstant.AUTHORIZATION_AUTHENTICATED_USER,
            AuthConstant.AUTHORIZATION_SUPPORT_USER,
            AuthConstant.AUTHORIZATION_SUPERPOWERS_SERVICE
    })
    public GenericAccountResponse getAccount(@RequestParam @NotBlank String userId) {
        this.validateAuthenticatedUser(userId);
        this.validateEnv();

        AccountDto accountDto = accountService.get(userId);

        GenericAccountResponse genericAccountResponse = new GenericAccountResponse(accountDto);
        return genericAccountResponse;
    }

因为代码不复杂,不讲述了。

三. 小关键点

通过之前的方法分析其实已经可以模糊总结出规律
一个方法的编写逻辑如下
通过参数获得一个封装好的对象
对对象进行处理
在大段的验证和报错处理后
结束方法

ps:一个好的代码会有大量的参数校验与异常处理

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值