hejianhao
2026-08-18 fb78f55a5b010d560a4673e4bf046133c07966c6
feat0806: 编辑诊所
1个文件已添加
4个文件已修改
249 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/ClinicAppService.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/utils/VzanHttpUtil.java 214 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/clinic/index.tsx 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/clinic/manage.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/clinic/service.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/ClinicAppService.java
@@ -1,25 +1,22 @@
package com.tcm.training.admin.member.service;
import cn.idev.excel.FastExcel;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.tcm.training.admin.agent.excel.ClinicAgentImportRow;
import com.tcm.training.admin.agent.entity.ProvincialAgent;
import com.tcm.training.admin.agent.excel.ClinicAgentImportRow;
import com.tcm.training.admin.agent.mapper.ProvincialAgentMapper;
import com.tcm.training.admin.common.DateTimes;
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.common.PhoneMasker;
import com.tcm.training.admin.member.excel.ClinicExcelRow;
import com.tcm.training.admin.member.dto.ClinicBankAccountUpdateRequest;
import com.tcm.training.admin.live.entity.Clinic;
import com.tcm.training.admin.live.mapper.ClinicMapper;
import java.util.Collections;
import com.tcm.training.admin.member.dto.ClinicBankAccountUpdateRequest;
import com.tcm.training.admin.member.excel.ClinicExcelRow;
import com.tcm.training.admin.member.mapper.ClinicQueryMapper;
import com.tcm.training.admin.member.row.ClinicBoundUserRow;
import com.tcm.training.admin.member.row.ClinicDetailRow;
import com.tcm.training.admin.member.mapper.ClinicQueryMapper;
import com.tcm.training.admin.member.row.ClinicListRow;
import com.tcm.training.admin.member.row.ClinicRegionOptionRow;
import com.tcm.training.admin.member.vo.ClinicBoundUserItemVO;
@@ -34,12 +31,7 @@
import java.io.IOException;
import java.net.URLEncoder;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.*;
@Service
public class ClinicAppService {
@@ -101,7 +93,7 @@
        Set<String> clinicNames = new LinkedHashSet<String>();
        Set<String> agentNames = new LinkedHashSet<String>();
        Map<String, String> bindingByClinicName = new LinkedHashMap<String, String>();
        Map<String, String> bindingByClinicName = new HashMap<String, String>();
        int rowNumber = 1;
        for (ClinicAgentImportRow row : rows) {
            rowNumber++;
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/utils/VzanHttpUtil.java
New file
@@ -0,0 +1,214 @@
package com.tcm.training.admin.utils;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
 * 微赞开放平台HTTP工具,负责Token获取、缓存和自动刷新。
 */
@Slf4j
@Component
public class VzanHttpUtil {
    private static final String TOKEN_URL = "https://openapi.njyqkj0ksyz.com/api/v2/token/get_token";
    private static final String ACCOUNT = "Vzan1247381477";
    private static final String SECRET_KEY = "b1bd524170574dc085a2d75adcd1c644 ";
    private static final long REFRESH_AHEAD_MILLIS = 60_000L;
    private static final DateTimeFormatter DATE_TIME_FORMATTER =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    private final Object refreshLock = new Object();
    private final RestOperations restOperations;
    private final ObjectMapper objectMapper;
    private final Clock clock;
    private volatile TokenSnapshot tokenSnapshot;
    @Autowired
    public VzanHttpUtil(ObjectMapper objectMapper) {
        this(createRestTemplate(), objectMapper, Clock.systemDefaultZone());
    }
    public VzanHttpUtil(RestOperations restOperations, ObjectMapper objectMapper, Clock clock) {
        this.restOperations = restOperations;
        this.objectMapper = objectMapper;
        this.clock = clock;
    }
    /**
     * 获取有效Token;Token不存在或即将过期时自动刷新。
     */
    public String getToken() {
        TokenSnapshot current = tokenSnapshot;
        if (isUsable(current)) {
            return current.token;
        }
        synchronized (refreshLock) {
            current = tokenSnapshot;
            if (isUsable(current)) {
                return current.token;
            }
            return requestAndCacheToken().token;
        }
    }
    /**
     * 忽略当前缓存并立即获取新Token。
     */
    public String refreshToken() {
        synchronized (refreshLock) {
            return requestAndCacheToken().token;
        }
    }
    private TokenSnapshot requestAndCacheToken() {
        validateCredentials();
        long timestamp = clock.millis();
        Map<String, String> body = new LinkedHashMap<String, String>();
        body.put("account", ACCOUNT.trim());
        body.put("signkey", md5Lowercase(SECRET_KEY.trim() + timestamp));
        body.put("timestamp", String.valueOf(timestamp));
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        HttpEntity<Map<String, String>> request = new HttpEntity<Map<String, String>>(body, headers);
        ResponseEntity<String> response = restOperations.postForEntity(TOKEN_URL, request, String.class);
        TokenSnapshot received = parseTokenResponse(response.getBody());
        tokenSnapshot = received;
        return received;
    }
    private TokenSnapshot parseTokenResponse(String responseBody) {
        if (!StringUtils.hasText(responseBody)) {
            throw new IllegalStateException("微赞Token接口返回为空");
        }
        try {
            JsonNode root = objectMapper.readTree(responseBody);
            if (!"0".equals(root.path("code").asText())) {
                throw new IllegalStateException("获取微赞Token失败: " + root.path("msg").asText("未知错误"));
            }
            JsonNode data = root.path("data");
            String token = data.path("token").asText();
            String tokenExpires = data.path("tokenExpires").asText();
            if (!StringUtils.hasText(token) || !StringUtils.hasText(tokenExpires)) {
                throw new IllegalStateException("微赞Token接口返回数据不完整");
            }
            return new TokenSnapshot(token, parseExpiresAt(tokenExpires));
        } catch (IllegalStateException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new IllegalStateException("解析微赞Token接口响应失败", ex);
        }
    }
    private Instant parseExpiresAt(String value) {
        String normalized = value.trim();
        try {
            long numericValue = Long.parseLong(normalized);
            if (numericValue < 10_000_000L) {
                return clock.instant().plusSeconds(numericValue);
            }
            return numericValue < 10_000_000_000L
                    ? Instant.ofEpochSecond(numericValue)
                    : Instant.ofEpochMilli(numericValue);
        } catch (NumberFormatException ignored) {
            // 继续兼容标准日期时间格式。
        }
        try {
            return Instant.parse(normalized);
        } catch (DateTimeParseException ignored) {
            // 继续兼容带时区和无时区格式。
        }
        try {
            return OffsetDateTime.parse(normalized).toInstant();
        } catch (DateTimeParseException ignored) {
            // 继续兼容微赞常用的本地日期时间格式。
        }
        try {
            LocalDateTime localDateTime = LocalDateTime.parse(normalized, DATE_TIME_FORMATTER);
            return localDateTime.atZone(ZoneId.systemDefault()).toInstant();
        } catch (DateTimeParseException ex) {
            throw new IllegalStateException("无法识别微赞Token过期时间: " + normalized, ex);
        }
    }
    private boolean isUsable(TokenSnapshot snapshot) {
        return snapshot != null
                && snapshot.expiresAt.toEpochMilli() - clock.millis() > REFRESH_AHEAD_MILLIS;
    }
    private void validateCredentials() {
        if (!StringUtils.hasText(ACCOUNT) || !StringUtils.hasText(SECRET_KEY)) {
            throw new IllegalStateException("请先配置微赞PAAS账号和客户密钥");
        }
    }
    private static String md5Lowercase(String text) {
        try {
            MessageDigest digest = MessageDigest.getInstance("MD5");
            byte[] bytes = digest.digest(text.getBytes(StandardCharsets.UTF_8));
            StringBuilder result = new StringBuilder(bytes.length * 2);
            for (byte value : bytes) {
                result.append(String.format("%02x", value & 0xff));
            }
            return result.toString();
        } catch (NoSuchAlgorithmException ex) {
            throw new IllegalStateException("当前运行环境不支持MD5", ex);
        }
    }
    private static RestTemplate createRestTemplate() {
        SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
        requestFactory.setConnectTimeout(10_000);
        requestFactory.setReadTimeout(10_000);
        return new RestTemplate(requestFactory);
    }
    private static final class TokenSnapshot {
        private final String token;
        private final Instant expiresAt;
        private TokenSnapshot(String token, Instant expiresAt) {
            this.token = token;
            this.expiresAt = expiresAt;
        }
    }
//    /**
//     * 启动时验证微赞Token接口配置和连通性,失败仅记录日志,不阻断应用启动。
//     */
//    @PostConstruct
//    public void init() {
//        if (!StringUtils.hasText(ACCOUNT) || !StringUtils.hasText(SECRET_KEY)) {
//            log.warn("微赞Token连通性测试已跳过:PAAS账号或客户密钥未配置");
//            return;
//        }
//        try {
//            String token = getToken();
//            log.info("微赞Token获取成功:{}", token);
//        } catch (Exception ex) {
//            log.error("微赞Token获取失败", ex);
//        }
//    }
}
tcm-training-admin/src/pages/member/clinic/index.tsx
@@ -141,7 +141,7 @@
    {
      title: '状态',
      dataIndex: 'status',
      width: 100,
      width: 150,
      render: (value) => (value === 1 ? '启用' : value === 2 ? '注销' : '禁用'),
    },
    {
@@ -151,8 +151,9 @@
    },
    {
      title: '操作',
      width: 100,
      width: 150,
      render: (_, record) => (
        <>
        <button
          type="button"
          className="member-clinic-table__action"
@@ -160,6 +161,8 @@
        >
          详情
        </button>
        {access["member:clinic:edit"] ? <button type="button" className="member-clinic-table__action" onClick={() => history.push(`/member/user/clinic/${record.id}/edit`)}>编辑</button> : null}
        </>
      ),
    },
  ];
tcm-training-admin/src/pages/member/clinic/manage.tsx
@@ -1,7 +1,7 @@
import { history, useAccess, useParams } from '@umijs/max';
import { Button, Form, Input, message } from 'antd';
import React, { useEffect, useState } from 'react';
import { getClinicDetail, updateClinicBankAccount } from './service';
import { getClinicDetail, updateClinic } from './service';
type FormValues = { bankName?: string; accountHolder?: string; accountNumber?: string; branchName?: string };
const ClinicManagePage: React.FC = () => {
@@ -13,7 +13,7 @@
  }, [params.id]);
  const submit = async () => {
    const values = await form.validateFields(); if (!params.id) return; setLoading(true);
    try { await updateClinicBankAccount(params.id, values); message.success('保存成功'); history.push(`/member/user/clinic/${params.id}`); }
    try { await updateClinic(params.id, values); message.success('保存成功'); history.push(`/member/user/clinic/${params.id}`); }
    finally { setLoading(false); }
  };
  return <div style={{ padding: 24, background: '#fff' }}><h2>编辑诊所</h2>
tcm-training-admin/src/pages/member/clinic/service.ts
@@ -56,7 +56,7 @@
  });
}
export async function updateClinicBankAccount(id: number | string, data: Record<string, any>) {
export async function updateClinic(id: number | string, data: Record<string, any>) {
  return request(`/clinics/${id}`, { method: 'PUT', data });
}
export async function importClinicAgentBindings(file: File) {