Jelajahi Sumber

chore: update danshop service and add backend utilities

Update danshop config, payment, order and logistics. Add FamilyInviteController, DanshopSyncService, QrCodeService and integer price migration SQL.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
User 4 bulan lalu
induk
melakukan
607cc13ed2

+ 137 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyInviteController.java

@@ -0,0 +1,137 @@
+package com.etotem.cfc.controller.family;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.QrCodeService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+@Tag(name = "家庭邀请", description = "家庭成员邀请二维码相关接口")
+@RestController
+@RequestMapping("/api/family/invite")
+public class FamilyInviteController {
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private QrCodeService qrCodeService;
+
+    /**
+     * 生成家庭邀请二维码
+     * 返回: base64 QR 码图片 + 邀请码 + 家庭名称
+     */
+    @Operation(summary = "生成家庭邀请二维码")
+    @PostMapping("/qrcode")
+    public Result<Map<String, Object>> generateQrCode(HttpServletRequest request) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return Result.error("用户没有家庭");
+        }
+
+        Family family = familyMapper.selectById(user.getFamilyId());
+        if (family == null) {
+            return Result.error("家庭不存在");
+        }
+
+        // 确保有邀请码
+        String inviteCode = family.getInviteCode();
+        if (inviteCode == null || inviteCode.isEmpty()) {
+            inviteCode = generateInviteCode();
+            family.setInviteCode(inviteCode);
+            family.setUpdatedAt(new Date());
+            familyMapper.updateById(family);
+        }
+
+        // 二维码内容:邀请码
+        String qrContent = inviteCode;
+        String qrBase64 = qrCodeService.generateQrCodeBase64(qrContent);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("inviteCode", inviteCode);
+        result.put("familyName", family.getName());
+        result.put("qrCodeBase64", "data:image/png;base64," + qrBase64);
+        result.put("familyId", family.getId());
+
+        return Result.success(result);
+    }
+
+    /**
+     * 通过邀请码接受邀请并加入家庭
+     */
+    @Operation(summary = "通过邀请码加入家庭")
+    @PostMapping("/accept-by-code")
+    public Result<String> acceptByCode(HttpServletRequest request,
+                                       @RequestBody Map<String, String> body) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+
+        String inviteCode = body.get("inviteCode");
+        if (inviteCode == null || inviteCode.trim().isEmpty()) {
+            return Result.error("邀请码不能为空");
+        }
+
+        // 查找家庭
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Family> wrapper =
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        wrapper.eq(Family::getInviteCode, inviteCode.trim());
+        Family family = familyMapper.selectOne(wrapper);
+
+        if (family == null) {
+            return Result.error("邀请码无效");
+        }
+
+        User currentUser = userMapper.selectById(userId);
+        if (currentUser == null) {
+            return Result.error("用户不存在");
+        }
+
+        if (currentUser.getFamilyId() != null) {
+            return Result.error("您已加入家庭,无法重复加入");
+        }
+
+        // 加入家庭
+        currentUser.setFamilyId(family.getId());
+        currentUser.setUpdatedAt(new Date());
+        userMapper.updateById(currentUser);
+
+        return Result.success("加入家庭成功");
+    }
+
+    private Long getUserId(HttpServletRequest request) {
+        Object userIdObj = request.getAttribute("userId");
+        if (userIdObj != null) {
+            return (Long) userIdObj;
+        }
+        String userIdHeader = request.getHeader("X-User-Id");
+        if (userIdHeader != null) {
+            return Long.parseLong(userIdHeader);
+        }
+        return null;
+    }
+
+    private String generateInviteCode() {
+        String uuid = java.util.UUID.randomUUID().toString().replace("-", "").toUpperCase();
+        return uuid.substring(0, 8);
+    }
+}

+ 114 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DanshopSyncService.java

@@ -0,0 +1,114 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.User;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+
+@Service
+public class DanshopSyncService {
+
+    @Value("${danshop.api-url}")
+    private String danshopApiUrl;
+
+    @Resource
+    private RestTemplate restTemplate;
+
+    /**
+     * 同步用户到 danshop,返回 danshop token 信息
+     * @return { accessToken, refreshToken } or null if sync fails
+     */
+    public Map<String, String> syncMember(User user) {
+        try {
+            // Build request body
+            Map<String, Object> request = new HashMap<>();
+            request.put("openid", user.getOpenid() != null ? user.getOpenid() : "");
+            request.put("unionid", user.getUnionid() != null ? user.getUnionid() : "");
+            request.put("phone", user.getPhone() != null ? user.getPhone() : "");
+            request.put("nickname", user.getNickname() != null ? user.getNickname() : "");
+            request.put("avatar", user.getAvatar() != null ? user.getAvatar() : "");
+
+            String url = danshopApiUrl + "/member/sync";
+            
+            @SuppressWarnings("unchecked")
+            Map<String, Object> response = restTemplate.postForObject(url, request, Map.class);
+            
+            if (response != null && response.get("code") != null && 
+                (response.get("code").equals(200) || response.get("code").equals(0) || response.get("code").equals("200"))) {
+                Object data = response.get("data");
+                if (data instanceof Map) {
+                    Map<String, Object> tokenData = (Map<String, Object>) data;
+                    Map<String, String> result = new HashMap<>();
+                    result.put("accessToken", tokenData.get("accessToken") != null ? tokenData.get("accessToken").toString() : null);
+                    result.put("refreshToken", tokenData.get("refreshToken") != null ? tokenData.get("refreshToken").toString() : null);
+                    return result;
+                }
+            }
+            return null;
+        } catch (Exception e) {
+            // Don't fail the whole login if danshop sync fails
+            // Log the error
+            System.err.println("Danshop sync failed for user " + user.getId() + ": " + e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 同步商品到 danshop
+     * @return data map if sync succeeded, null otherwise
+     */
+    public Map<String, Object> syncProduct(Long cfcProductId, String name, String description,
+                                             String coverImage, Integer price, Integer stock,
+                                             String productType, Integer memberPrice,
+                                             String status, String vendorName) {
+        try {
+            Map<String, Object> request = new HashMap<>();
+            request.put("cfcProductId", cfcProductId);
+            request.put("name", name != null ? name : "");
+            request.put("description", description != null ? description : "");
+            request.put("coverImage", coverImage != null ? coverImage : "");
+            request.put("price", price != null ? price : 0);
+            request.put("stock", stock != null ? stock : 0);
+            request.put("productType", productType != null ? productType : "");
+            request.put("memberPrice", memberPrice != null ? memberPrice : 0);
+            request.put("status", status != null ? status : "");
+            request.put("vendorName", vendorName != null ? vendorName : "");
+
+            String url = danshopApiUrl + "/product/sync";
+
+            @SuppressWarnings("unchecked")
+            Map<String, Object> response = restTemplate.postForObject(url, request, Map.class);
+
+            if (response != null && response.get("code") != null &&
+                (response.get("code").equals(200) || response.get("code").equals(0))) {
+                return (Map<String, Object>) response.get("data");
+            }
+            return null;
+        } catch (Exception e) {
+            System.err.println("Danshop product sync failed for product " + cfcProductId + ": " + e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 同步 Product 实体到 danshop
+     */
+    public Map<String, Object> syncProduct(Product product) {
+        return syncProduct(
+            product.getId(),
+            product.getName(),
+            product.getDescription(),
+            product.getCoverImage(),
+            product.getPrice(),
+            product.getStock(),
+            product.getProductType(),
+            product.getMemberPrice(),
+            product.getStatus(),
+            product.getVendorName()
+        );
+    }
+}

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/QrCodeService.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.service;
+
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.EncodeHintType;
+import com.google.zxing.WriterException;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.qrcode.QRCodeWriter;
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class QrCodeService {
+
+    private static final int QR_SIZE = 400;
+    private static final int MARGIN = 20;
+
+    /**
+     * 生成二维码图片,返回 Base64 编码的 PNG 数据
+     */
+    public String generateQrCodeBase64(String content) {
+        try {
+            QRCodeWriter qrCodeWriter = new QRCodeWriter();
+            Map<EncodeHintType, Object> hints = new HashMap<>();
+            hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
+            hints.put(EncodeHintType.MARGIN, 2);
+
+            BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_SIZE, QR_SIZE, hints);
+
+            BufferedImage image = new BufferedImage(QR_SIZE, QR_SIZE, BufferedImage.TYPE_INT_RGB);
+            for (int x = 0; x < QR_SIZE; x++) {
+                for (int y = 0; y < QR_SIZE; y++) {
+                    image.setRGB(x, y, bitMatrix.get(x, y) ? 0xFF000000 : 0xFFFFFFFF);
+                }
+            }
+
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            ImageIO.write(image, "PNG", baos);
+            byte[] bytes = baos.toByteArray();
+            return Base64.getEncoder().encodeToString(bytes);
+        } catch (WriterException | IOException e) {
+            log.error("生成二维码失败", e);
+            return null;
+        }
+    }
+
+    /**
+     * 生成带 Logo 的二维码(可选)
+     */
+    public String generateQrCodeWithLogoBase64(String content, BufferedImage logo) {
+        try {
+            QRCodeWriter qrCodeWriter = new QRCodeWriter();
+            Map<EncodeHintType, Object> hints = new HashMap<>();
+            hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
+            hints.put(EncodeHintType.MARGIN, 2);
+
+            BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_SIZE, QR_SIZE, hints);
+
+            BufferedImage image = new BufferedImage(QR_SIZE, QR_SIZE, BufferedImage.TYPE_INT_RGB);
+            for (int x = 0; x < QR_SIZE; x++) {
+                for (int y = 0; y < QR_SIZE; y++) {
+                    image.setRGB(x, y, bitMatrix.get(x, y) ? 0xFF000000 : 0xFFFFFFFF);
+                }
+            }
+
+            // 在中心绘制 Logo
+            if (logo != null) {
+                Graphics2D g = image.createGraphics();
+                int logoSize = QR_SIZE / 5;
+                int logoX = (QR_SIZE - logoSize) / 2;
+                int logoY = (QR_SIZE - logoSize) / 2;
+                g.drawImage(logo, logoX, logoY, logoSize, logoSize, null);
+                g.dispose();
+            }
+
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            ImageIO.write(image, "PNG", baos);
+            byte[] bytes = baos.toByteArray();
+            return Base64.getEncoder().encodeToString(bytes);
+        } catch (WriterException | IOException e) {
+            log.error("生成带Logo二维码失败", e);
+            return null;
+        }
+    }
+}

+ 274 - 0
cfc-backend/src/main/resources/migration_integer_prices.sql

@@ -0,0 +1,274 @@
+-- =============================================
+-- Migration: Integer Prices (分/千分比/百分位)
+-- Converts DECIMAL price fields to INT across ALL tables
+-- Run manually: mysql -u cfc -p cfc < migration_integer_prices.sql
+-- =============================================
+-- Convention:
+--   Price fields:  DECIMAL(x,2) → INT (分, 1元=100)
+--   Rate fields:   DECIMAL(x,2) → INT (千分比, 10% = 1000)
+--   Rating fields: DECIMAL(x,2) → INT (百分位, 5.00 = 500)
+--   Weight fields: DECIMAL(x,2) → INT (百分位, 50.00 = 5000) [energy only]
+-- =============================================
+
+SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0;
+SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0;
+
+-- =============================================
+-- 1. products: price, member_price, profit_rate
+-- =============================================
+-- price DECIMAL(10,2) → INT (分)
+ALTER TABLE products ADD COLUMN price_int INT NOT NULL DEFAULT 0 COMMENT '价格(分)' AFTER price;
+UPDATE products SET price_int = ROUND(price * 100);
+ALTER TABLE products DROP COLUMN price;
+ALTER TABLE products RENAME COLUMN price_int TO price;
+
+-- member_price DECIMAL(10,2) → INT (分)
+ALTER TABLE products ADD COLUMN member_price_int INT COMMENT '会员价(分)' AFTER member_price;
+UPDATE products SET member_price_int = ROUND(member_price * 100) WHERE member_price IS NOT NULL;
+ALTER TABLE products DROP COLUMN member_price;
+ALTER TABLE products RENAME COLUMN member_price_int TO member_price;
+
+-- profit_rate DECIMAL(5,2) → INT (千分比)
+ALTER TABLE products ADD COLUMN profit_rate_int INT DEFAULT 0 COMMENT '平台利润率(千分比)' AFTER profit_rate;
+UPDATE products SET profit_rate_int = ROUND(profit_rate * 1000);
+ALTER TABLE products DROP COLUMN profit_rate;
+ALTER TABLE products RENAME COLUMN profit_rate_int TO profit_rate;
+
+-- =============================================
+-- 2. product_orders: unit_price, total_amount
+-- =============================================
+ALTER TABLE product_orders ADD COLUMN unit_price_int INT NOT NULL COMMENT '单价(分)' AFTER unit_price;
+UPDATE product_orders SET unit_price_int = ROUND(unit_price * 100);
+ALTER TABLE product_orders DROP COLUMN unit_price;
+ALTER TABLE product_orders RENAME COLUMN unit_price_int TO unit_price;
+
+ALTER TABLE product_orders ADD COLUMN total_amount_int INT NOT NULL COMMENT '总金额(分)' AFTER total_amount;
+UPDATE product_orders SET total_amount_int = ROUND(total_amount * 100);
+ALTER TABLE product_orders DROP COLUMN total_amount;
+ALTER TABLE product_orders RENAME COLUMN total_amount_int TO total_amount;
+
+-- =============================================
+-- 3. activities: price
+-- =============================================
+ALTER TABLE activities ADD COLUMN price_int INT NOT NULL DEFAULT 0 COMMENT '价格(分)' AFTER price;
+UPDATE activities SET price_int = ROUND(price * 100);
+ALTER TABLE activities DROP COLUMN price;
+ALTER TABLE activities RENAME COLUMN price_int TO price;
+
+-- =============================================
+-- 4. guide_packages: price, commission_rate
+-- =============================================
+ALTER TABLE guide_packages ADD COLUMN price_int INT NOT NULL DEFAULT 0 COMMENT '价格(分)' AFTER price;
+UPDATE guide_packages SET price_int = ROUND(price * 100);
+ALTER TABLE guide_packages DROP COLUMN price;
+ALTER TABLE guide_packages RENAME COLUMN price_int TO price;
+
+ALTER TABLE guide_packages ADD COLUMN commission_rate_int INT DEFAULT 0 COMMENT '佣金率(千分比)' AFTER commission_rate;
+UPDATE guide_packages SET commission_rate_int = ROUND(commission_rate * 1000);
+ALTER TABLE guide_packages DROP COLUMN commission_rate;
+ALTER TABLE guide_packages RENAME COLUMN commission_rate_int TO commission_rate;
+
+-- =============================================
+-- 5. guide_package_templates: reference_price, min_price
+-- =============================================
+ALTER TABLE guide_package_templates ADD COLUMN reference_price_int INT NOT NULL DEFAULT 0 COMMENT '参考价(分)' AFTER reference_price;
+UPDATE guide_package_templates SET reference_price_int = ROUND(reference_price * 100);
+ALTER TABLE guide_package_templates DROP COLUMN reference_price;
+ALTER TABLE guide_package_templates RENAME COLUMN reference_price_int TO reference_price;
+
+ALTER TABLE guide_package_templates ADD COLUMN min_price_int INT NOT NULL DEFAULT 0 COMMENT '最低价(分)' AFTER min_price;
+UPDATE guide_package_templates SET min_price_int = ROUND(min_price * 100);
+ALTER TABLE guide_package_templates DROP COLUMN min_price;
+ALTER TABLE guide_package_templates RENAME COLUMN min_price_int TO min_price;
+
+-- =============================================
+-- 6. guide_families: service_price
+-- =============================================
+ALTER TABLE guide_families ADD COLUMN service_price_int INT DEFAULT 0 COMMENT '服务价格(分)' AFTER service_price;
+UPDATE guide_families SET service_price_int = ROUND(service_price * 100) WHERE service_price IS NOT NULL;
+ALTER TABLE guide_families DROP COLUMN service_price;
+ALTER TABLE guide_families RENAME COLUMN service_price_int TO service_price;
+
+-- =============================================
+-- 7. package_orders: price, platform_fee
+-- =============================================
+ALTER TABLE package_orders ADD COLUMN price_int INT NOT NULL DEFAULT 0 COMMENT '支付金额(分)' AFTER price;
+UPDATE package_orders SET price_int = ROUND(price * 100);
+ALTER TABLE package_orders DROP COLUMN price;
+ALTER TABLE package_orders RENAME COLUMN price_int TO price;
+
+ALTER TABLE package_orders ADD COLUMN platform_fee_int INT DEFAULT 0 COMMENT '平台服务费(分)' AFTER platform_fee;
+UPDATE package_orders SET platform_fee_int = ROUND(platform_fee * 100) WHERE platform_fee IS NOT NULL;
+ALTER TABLE package_orders DROP COLUMN platform_fee;
+ALTER TABLE package_orders RENAME COLUMN platform_fee_int TO platform_fee;
+
+-- =============================================
+-- 8. payment_orders: amount
+-- =============================================
+ALTER TABLE payment_orders ADD COLUMN amount_int INT NOT NULL DEFAULT 0 COMMENT '金额(分)' AFTER amount;
+UPDATE payment_orders SET amount_int = ROUND(amount * 100);
+ALTER TABLE payment_orders DROP COLUMN amount;
+ALTER TABLE payment_orders RENAME COLUMN amount_int TO amount;
+
+-- =============================================
+-- 9. commission_records: commission_amount, profit_rate
+-- =============================================
+ALTER TABLE commission_records ADD COLUMN commission_amount_int INT NOT NULL DEFAULT 0 COMMENT '佣金金额(分)' AFTER commission_amount;
+UPDATE commission_records SET commission_amount_int = ROUND(commission_amount * 100);
+ALTER TABLE commission_records DROP COLUMN commission_amount;
+ALTER TABLE commission_records RENAME COLUMN commission_amount_int TO commission_amount;
+
+ALTER TABLE commission_records ADD COLUMN profit_rate_int INT DEFAULT 0 COMMENT '利润率(千分比)' AFTER profit_rate;
+UPDATE commission_records SET profit_rate_int = ROUND(profit_rate * 1000);
+ALTER TABLE commission_records DROP COLUMN profit_rate;
+ALTER TABLE commission_records RENAME COLUMN profit_rate_int TO profit_rate;
+
+-- =============================================
+-- 10. withdrawal_requests: amount
+-- =============================================
+ALTER TABLE withdrawal_requests ADD COLUMN amount_int INT NOT NULL DEFAULT 0 COMMENT '金额(分)' AFTER amount;
+UPDATE withdrawal_requests SET amount_int = ROUND(amount * 100);
+ALTER TABLE withdrawal_requests DROP COLUMN amount;
+ALTER TABLE withdrawal_requests RENAME COLUMN amount_int TO amount;
+
+-- =============================================
+-- 11. family_memberships: amount
+-- =============================================
+ALTER TABLE family_memberships ADD COLUMN amount_int INT DEFAULT 0 COMMENT '支付金额(分)' AFTER amount;
+UPDATE family_memberships SET amount_int = ROUND(amount * 100) WHERE amount IS NOT NULL;
+ALTER TABLE family_memberships DROP COLUMN amount;
+ALTER TABLE family_memberships RENAME COLUMN amount_int TO amount;
+
+-- =============================================
+-- 12. membership_levels: price_monthly, price_yearly
+-- =============================================
+ALTER TABLE membership_levels ADD COLUMN price_monthly_int INT DEFAULT 0 COMMENT '月费(分)' AFTER price_monthly;
+UPDATE membership_levels SET price_monthly_int = ROUND(price_monthly * 100) WHERE price_monthly IS NOT NULL;
+ALTER TABLE membership_levels DROP COLUMN price_monthly;
+ALTER TABLE membership_levels RENAME COLUMN price_monthly_int TO price_monthly;
+
+ALTER TABLE membership_levels ADD COLUMN price_yearly_int INT DEFAULT 0 COMMENT '年费(分)' AFTER price_yearly;
+UPDATE membership_levels SET price_yearly_int = ROUND(price_yearly * 100) WHERE price_yearly IS NOT NULL;
+ALTER TABLE membership_levels DROP COLUMN price_yearly;
+ALTER TABLE membership_levels RENAME COLUMN price_yearly_int TO price_yearly;
+
+-- =============================================
+-- 13. insurance_policies: premium
+-- =============================================
+ALTER TABLE insurance_policies ADD COLUMN premium_int INT DEFAULT 0 COMMENT '保费(分)' AFTER premium;
+UPDATE insurance_policies SET premium_int = ROUND(premium * 100) WHERE premium IS NOT NULL;
+ALTER TABLE insurance_policies DROP COLUMN premium;
+ALTER TABLE insurance_policies RENAME COLUMN premium_int TO premium;
+
+-- =============================================
+-- 14. service_contents: price
+-- =============================================
+ALTER TABLE service_contents ADD COLUMN price_int INT DEFAULT 0 COMMENT '价格(分)' AFTER price;
+UPDATE service_contents SET price_int = ROUND(price * 100) WHERE price IS NOT NULL;
+ALTER TABLE service_contents DROP COLUMN price;
+ALTER TABLE service_contents RENAME COLUMN price_int TO price;
+
+-- =============================================
+-- 15. task_template_packages: price, platform_fee
+-- =============================================
+ALTER TABLE task_template_packages ADD COLUMN price_int INT NOT NULL DEFAULT 0 COMMENT '售价(分)' AFTER price;
+UPDATE task_template_packages SET price_int = ROUND(price * 100);
+ALTER TABLE task_template_packages DROP COLUMN price;
+ALTER TABLE task_template_packages RENAME COLUMN price_int TO price;
+
+ALTER TABLE task_template_packages ADD COLUMN platform_fee_int INT DEFAULT 0 COMMENT '平台服务费(千分比)' AFTER platform_fee;
+UPDATE task_template_packages SET platform_fee_int = ROUND(platform_fee * 1000) WHERE platform_fee IS NOT NULL;
+ALTER TABLE task_template_packages DROP COLUMN platform_fee;
+ALTER TABLE task_template_packages RENAME COLUMN platform_fee_int TO platform_fee;
+
+-- =============================================
+-- 16. guides: rating, commission_rate
+-- =============================================
+ALTER TABLE guides ADD COLUMN rating_int INT DEFAULT 500 COMMENT '评分(百分位,5.00=500)' AFTER rating;
+UPDATE guides SET rating_int = ROUND(rating * 100) WHERE rating IS NOT NULL;
+ALTER TABLE guides DROP COLUMN rating;
+ALTER TABLE guides RENAME COLUMN rating_int TO rating;
+
+ALTER TABLE guides ADD COLUMN commission_rate_int INT DEFAULT 1000 COMMENT '佣金率(千分比,10%=1000)' AFTER commission_rate;
+UPDATE guides SET commission_rate_int = ROUND(commission_rate * 1000) WHERE commission_rate IS NOT NULL;
+ALTER TABLE guides DROP COLUMN commission_rate;
+ALTER TABLE guides RENAME COLUMN commission_rate_int TO commission_rate;
+
+-- =============================================
+-- 17. teachers: rating
+-- =============================================
+ALTER TABLE teachers ADD COLUMN rating_int INT DEFAULT 500 COMMENT '评分(百分位,5.00=500)' AFTER rating;
+UPDATE teachers SET rating_int = ROUND(rating * 100) WHERE rating IS NOT NULL;
+ALTER TABLE teachers DROP COLUMN rating;
+ALTER TABLE teachers RENAME COLUMN rating_int TO rating;
+
+-- =============================================
+-- 18. energy_dimension_configs: weight
+-- =============================================
+ALTER TABLE energy_dimension_configs ADD COLUMN weight_int INT NOT NULL DEFAULT 0 COMMENT '权重(千分比,50%=5000)' AFTER weight;
+UPDATE energy_dimension_configs SET weight_int = ROUND(weight * 10000);
+ALTER TABLE energy_dimension_configs DROP COLUMN weight;
+ALTER TABLE energy_dimension_configs RENAME COLUMN weight_int TO weight;
+
+-- =============================================
+-- 19. energy_source_configs: ratio
+-- =============================================
+ALTER TABLE energy_source_configs ADD COLUMN ratio_int INT NOT NULL DEFAULT 10000 COMMENT '比例(千分比,100%=10000)' AFTER ratio;
+UPDATE energy_source_configs SET ratio_int = ROUND(ratio * 10000);
+ALTER TABLE energy_source_configs DROP COLUMN ratio;
+ALTER TABLE energy_source_configs RENAME COLUMN ratio_int TO ratio;
+
+-- =============================================
+-- 20. energy_logs: amount
+-- =============================================
+-- energy_logs.amount was already INT in original schema (energy points, not money)
+-- No migration needed
+
+-- =============================================
+-- 21. finance_checkin: amount
+-- =============================================
+ALTER TABLE finance_checkin ADD COLUMN amount_int INT DEFAULT 0 COMMENT '金额(分)' AFTER amount;
+UPDATE finance_checkin SET amount_int = ROUND(amount * 100) WHERE amount IS NOT NULL;
+ALTER TABLE finance_checkin DROP COLUMN amount;
+ALTER TABLE finance_checkin RENAME COLUMN amount_int TO amount;
+
+-- =============================================
+-- 22. dan_assessment_results: weight (child body weight)
+-- =============================================
+-- This is body weight in KG, stored as percentile: 50.00 KG = 5000
+ALTER TABLE dan_assessment_results ADD COLUMN weight_int INT COMMENT '体重(KG百分位,50.00=5000)' AFTER weight;
+UPDATE dan_assessment_results SET weight_int = ROUND(weight * 100) WHERE weight IS NOT NULL;
+ALTER TABLE dan_assessment_results DROP COLUMN weight;
+ALTER TABLE dan_assessment_results RENAME COLUMN weight_int TO weight;
+
+-- =============================================
+-- 23. assessment_results: weight (energy weight)
+-- =============================================
+ALTER TABLE assessment_results ADD COLUMN weight_int INT DEFAULT 0 COMMENT '权重(千分比)' AFTER weight;
+UPDATE assessment_results SET weight_int = ROUND(weight * 10000) WHERE weight IS NOT NULL;
+ALTER TABLE assessment_results DROP COLUMN weight;
+ALTER TABLE assessment_results RENAME COLUMN weight_int TO weight;
+
+-- =============================================
+-- 24. game_records: score
+-- =============================================
+-- game_records.score was already INT in original schema
+-- No migration needed
+
+-- =============================================
+-- 25. points_logs: amount
+-- =============================================
+-- points_logs.amount was already INT in original schema (points, not money)
+-- No migration needed
+
+SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS;
+SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS;
+
+-- =============================================
+-- Verification queries (run after migration)
+-- =============================================
+-- SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE 
+-- FROM INFORMATION_SCHEMA.COLUMNS 
+-- WHERE TABLE_SCHEMA = 'cfc' AND DATA_TYPE = 'decimal'
+-- ORDER BY TABLE_NAME, COLUMN_NAME;
+-- Expected: 0 rows

+ 9 - 4
dan/danshop/src/main/java/com/etotem/danshop/config/CfcBackendClient.java

@@ -1,9 +1,14 @@
 package com.etotem.danshop.config;
 
-import okhttp3.*;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
-import com.alibaba.fastjson.JSONObject;
 import java.io.IOException;
 
 @Component
@@ -31,7 +36,7 @@ public class CfcBackendClient {
                 if (!response.isSuccessful()) return null;
                 String body = response.body().string();
                 JSONObject json = JSONObject.parseObject(body);
-                if (json.getIntValue("code") == 0) {
+                if (json.getIntValue("code") == 200) {
                     return json.getJSONObject("data").getLong("userId");
                 }
             }
@@ -50,7 +55,7 @@ public class CfcBackendClient {
             body.put("reason", reason);
             Request request = new Request.Builder()
                     .url(cfcUrl + "/api/energy/grant")
-                    .post(RequestBody.create(JSON_TYPE, body.toJSONString()))
+                    .post(RequestBody.create(JSON_TYPE, body.toString()))
                     .build();
             CLIENT.newCall(request).execute();
         } catch (IOException ignored) {}

+ 93 - 0
dan/danshop/src/main/java/com/etotem/danshop/config/JwtAuthFilter.java

@@ -0,0 +1,93 @@
+package com.etotem.danshop.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.annotation.Resource;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * JWT Token 验证过滤器
+ * 
+ * 从 Authorization header 提取 Bearer token,调用 cfc-backend /api/auth/verify 验证,
+ * 验证通过后将 userId 设置到 request attribute 中,供 Controller 通过 @RequestAttribute 获取。
+ * 
+ * 公开路径(无需登录):
+ *   - /api/shop/category/**
+ *   - /api/shop/product/list
+ *   - /api/shop/product/detail
+ *   - /api/shop/logistics/**
+ */
+@Component
+public class JwtAuthFilter extends OncePerRequestFilter {
+
+    @Resource
+    private CfcBackendClient cfcBackendClient;
+
+    private static final String[] PUBLIC_PATHS = {
+        "/api/shop/category/",
+        "/api/shop/product/list",
+        "/api/shop/product/detail",
+        "/api/shop/logistics/",
+    };
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request,
+                                     HttpServletResponse response,
+                                     FilterChain filterChain) throws ServletException, IOException {
+
+        // 放行 OPTIONS 请求(CORS 预检)
+        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
+            filterChain.doFilter(request, response);
+            return;
+        }
+
+        String path = request.getRequestURI();
+
+        // 检查是否为公开路径
+        for (String publicPath : PUBLIC_PATHS) {
+            if (path.equals(publicPath) || path.startsWith(publicPath)) {
+                filterChain.doFilter(request, response);
+                return;
+            }
+        }
+
+        // 提取 Bearer token
+        String authHeader = request.getHeader("Authorization");
+        if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
+            writeUnauthorized(response, "缺少Token");
+            return;
+        }
+
+        String token = authHeader.substring(7);
+
+        // 调用 cfc-backend 验证 token
+        Long userId = cfcBackendClient.verifyToken(token);
+        if (userId == null) {
+            writeUnauthorized(response, "Token无效或已过期");
+            return;
+        }
+
+        // 设置 userId 到 request attribute,供 Controller 使用
+        request.setAttribute("userId", userId);
+
+        filterChain.doFilter(request, response);
+    }
+
+    private void writeUnauthorized(HttpServletResponse response, String message) throws IOException {
+        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+        response.setContentType("application/json;charset=UTF-8");
+        Map<String, Object> body = new HashMap<>();
+        body.put("code", 401);
+        body.put("message", message);
+        response.getWriter().write(new ObjectMapper().writeValueAsString(body));
+    }
+}

+ 16 - 1
dan/danshop/src/main/java/com/etotem/danshop/config/WebMvcConfig.java

@@ -1,13 +1,19 @@
 package com.etotem.danshop.config;
 
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.servlet.config.annotation.CorsRegistry;
-import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
+import javax.annotation.Resource;
+
 @Configuration
 public class WebMvcConfig implements WebMvcConfigurer {
 
+    @Resource
+    private JwtAuthFilter jwtAuthFilter;
+
     @Override
     public void addCorsMappings(CorsRegistry registry) {
         registry.addMapping("/**")
@@ -17,4 +23,13 @@ public class WebMvcConfig implements WebMvcConfigurer {
                 .allowCredentials(true)
                 .maxAge(3600);
     }
+
+    @Bean
+    public FilterRegistrationBean<JwtAuthFilter> jwtAuthFilterRegistration() {
+        FilterRegistrationBean<JwtAuthFilter> registration = new FilterRegistrationBean<>();
+        registration.setFilter(jwtAuthFilter);
+        registration.addUrlPatterns("/api/shop/*", "/api/seller/*");
+        registration.setOrder(1);
+        return registration;
+    }
 }

+ 7 - 3
dan/danshop/src/main/java/com/etotem/danshop/controller/PaymentController.java

@@ -5,11 +5,15 @@ import com.alibaba.fastjson2.JSONObject;
 import com.etotem.danshop.common.Result;
 import com.etotem.danshop.service.OrderService;
 import com.etotem.danshop.service.PaymentService;
-import okhttp3.*;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
 import org.springframework.beans.factory.annotation.Value;
-import org.springframework.web.bind.annotation.*;
+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 javax.annotation.Resource;
-import java.io.IOException;
 
 @RestController
 @RequestMapping("/api/shop/payment")

+ 1 - 1
dan/danshop/src/main/java/com/etotem/danshop/service/OrderService.java

@@ -12,7 +12,7 @@ import com.etotem.danshop.mapper.*;
 import com.etotem.danshop.util.OrderNoGenerator;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import okhttp3.*;
-import org.springframework.beans.factory.annotation.annotation.Value;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;

+ 9 - 5
dan/danshop/src/main/java/com/etotem/danshop/util/LogisticsUtil.java

@@ -1,9 +1,13 @@
 package com.etotem.danshop.util;
 
-import com.alibaba.fastjson.JSON;
-import com.alibaba.fastjson.JSONArray;
-import com.alibaba.fastjson.JSONObject;
-import okhttp3.*;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
 import java.io.IOException;
@@ -33,7 +37,7 @@ public class LogisticsUtil {
 
         Request request = new Request.Builder()
                 .url(apiUrl)
-                .post(RequestBody.create(JSON_TYPE, body.toJSONString()))
+                .post(RequestBody.create(JSON_TYPE, JSON.toJSONString(body)))
                 .build();
 
         try (Response response = new OkHttpClient().newCall(request).execute()) {