feat(mobile): integrate PlantingService with real API

- Add PlantingService for planting order management
  - createOrder: Create new planting order
  - selectProvinceCity: Select province and city
  - confirmProvinceCity: Confirm province/city selection
  - payOrder: Pay for order
  - getOrder/getMyOrders: Query orders
  - cancelOrder: Cancel unpaid orders
- Register PlantingService in DI container
- Update planting_quantity_page to create order before navigation
- Update planting_location_page to call real API endpoints
- Fix referral-service event-ack.publisher to use KafkaService

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-09 22:28:36 -08:00
parent ba5b6141a3
commit dfa21c0280
5 changed files with 418 additions and 48 deletions

View File

@ -1,5 +1,5 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { ClientKafka } from '@nestjs/microservices';
import { Injectable, Logger } from '@nestjs/common';
import { KafkaService } from '../messaging/kafka.service';
/**
*
@ -30,10 +30,7 @@ export class EventAckPublisher {
private readonly logger = new Logger(EventAckPublisher.name);
private readonly serviceName = 'referral-service';
constructor(
@Inject('KAFKA_SERVICE')
private readonly kafkaClient: ClientKafka,
) {}
constructor(private readonly kafkaService: KafkaService) {}
/**
*
@ -48,9 +45,10 @@ export class EventAckPublisher {
};
try {
this.kafkaClient.emit('planting.events.ack', {
await this.kafkaService.publish({
topic: 'planting.events.ack',
key: eventId,
value: JSON.stringify(ackMessage),
value: ackMessage,
});
this.logger.log(`[ACK] ✓ Sent success confirmation for event ${eventId} (${eventType})`);
@ -73,9 +71,10 @@ export class EventAckPublisher {
};
try {
this.kafkaClient.emit('planting.events.ack', {
await this.kafkaService.publish({
topic: 'planting.events.ack',
key: eventId,
value: JSON.stringify(ackMessage),
value: ackMessage,
});
this.logger.warn(`[ACK] ✗ Sent failure confirmation for event ${eventId}: ${errorMessage}`);

View File

@ -7,6 +7,7 @@ import '../services/referral_service.dart';
import '../services/authorization_service.dart';
import '../services/deposit_service.dart';
import '../services/wallet_service.dart';
import '../services/planting_service.dart';
// Storage Providers
final secureStorageProvider = Provider<SecureStorage>((ref) {
@ -58,6 +59,12 @@ final walletServiceProvider = Provider<WalletService>((ref) {
return WalletService(apiClient: apiClient);
});
// Planting Service Provider
final plantingServiceProvider = Provider<PlantingService>((ref) {
final apiClient = ref.watch(apiClientProvider);
return PlantingService(apiClient: apiClient);
});
// Override provider with initialized instance
ProviderContainer createProviderContainer(LocalStorage localStorage) {
return ProviderContainer(

View File

@ -0,0 +1,299 @@
import 'package:flutter/foundation.dart';
import '../network/api_client.dart';
///
enum PlantingOrderStatus {
created, //
provinceCitySelected, //
provinceCityConfirmed, //
paid, //
fundAllocated, //
cancelled, //
}
///
class PlantingOrder {
final String orderNo;
final int treeCount;
final double totalAmount;
final PlantingOrderStatus status;
final String? selectedProvince;
final String? selectedCity;
final DateTime? provinceCitySelectedAt;
final DateTime? provinceCityConfirmedAt;
final DateTime? paidAt;
final bool isMiningEnabled;
final DateTime createdAt;
PlantingOrder({
required this.orderNo,
required this.treeCount,
required this.totalAmount,
required this.status,
this.selectedProvince,
this.selectedCity,
this.provinceCitySelectedAt,
this.provinceCityConfirmedAt,
this.paidAt,
required this.isMiningEnabled,
required this.createdAt,
});
factory PlantingOrder.fromJson(Map<String, dynamic> json) {
return PlantingOrder(
orderNo: json['orderNo'] ?? '',
treeCount: json['treeCount'] ?? 0,
totalAmount: (json['totalAmount'] ?? 0).toDouble(),
status: _parseStatus(json['status']),
selectedProvince: json['selectedProvince'],
selectedCity: json['selectedCity'],
provinceCitySelectedAt: json['provinceCitySelectedAt'] != null
? DateTime.parse(json['provinceCitySelectedAt'])
: null,
provinceCityConfirmedAt: json['provinceCityConfirmedAt'] != null
? DateTime.parse(json['provinceCityConfirmedAt'])
: null,
paidAt: json['paidAt'] != null ? DateTime.parse(json['paidAt']) : null,
isMiningEnabled: json['isMiningEnabled'] ?? false,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: DateTime.now(),
);
}
static PlantingOrderStatus _parseStatus(String? status) {
switch (status) {
case 'CREATED':
return PlantingOrderStatus.created;
case 'PROVINCE_CITY_SELECTED':
return PlantingOrderStatus.provinceCitySelected;
case 'PROVINCE_CITY_CONFIRMED':
return PlantingOrderStatus.provinceCityConfirmed;
case 'PAID':
return PlantingOrderStatus.paid;
case 'FUND_ALLOCATED':
return PlantingOrderStatus.fundAllocated;
case 'CANCELLED':
return PlantingOrderStatus.cancelled;
default:
return PlantingOrderStatus.created;
}
}
}
///
class CreateOrderResponse {
final String orderNo;
final int treeCount;
final double totalAmount;
final double pricePerTree;
CreateOrderResponse({
required this.orderNo,
required this.treeCount,
required this.totalAmount,
required this.pricePerTree,
});
factory CreateOrderResponse.fromJson(Map<String, dynamic> json) {
return CreateOrderResponse(
orderNo: json['orderNo'] ?? '',
treeCount: json['treeCount'] ?? 0,
totalAmount: (json['totalAmount'] ?? 0).toDouble(),
pricePerTree: (json['pricePerTree'] ?? 2199).toDouble(),
);
}
}
///
///
///
class PlantingService {
final ApiClient _apiClient;
PlantingService({required ApiClient apiClient}) : _apiClient = apiClient;
///
///
/// [treeCount]
///
Future<CreateOrderResponse> createOrder(int treeCount) async {
try {
debugPrint('[PlantingService] 创建认种订单: treeCount=$treeCount');
final response = await _apiClient.post(
'/planting/orders',
data: {'treeCount': treeCount},
);
if (response.statusCode == 201 || response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
debugPrint('[PlantingService] 订单创建成功: ${data['orderNo']}');
return CreateOrderResponse.fromJson(data);
}
throw Exception('创建订单失败: ${response.statusCode}');
} catch (e) {
debugPrint('[PlantingService] 创建订单失败: $e');
rethrow;
}
}
///
///
/// [orderNo]
/// [provinceCode]
/// [cityCode]
Future<PlantingOrder> selectProvinceCity(
String orderNo,
String provinceCode,
String cityCode,
) async {
try {
debugPrint('[PlantingService] 选择省市: orderNo=$orderNo, province=$provinceCode, city=$cityCode');
final response = await _apiClient.post(
'/planting/orders/$orderNo/select-province-city',
data: {
'provinceCode': provinceCode,
'cityCode': cityCode,
},
);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
debugPrint('[PlantingService] 省市选择成功');
return PlantingOrder.fromJson(data);
}
throw Exception('选择省市失败: ${response.statusCode}');
} catch (e) {
debugPrint('[PlantingService] 选择省市失败: $e');
rethrow;
}
}
///
///
/// [orderNo]
///
Future<PlantingOrder> confirmProvinceCity(String orderNo) async {
try {
debugPrint('[PlantingService] 确认省市选择: orderNo=$orderNo');
final response = await _apiClient.post(
'/planting/orders/$orderNo/confirm-province-city',
);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
debugPrint('[PlantingService] 省市确认成功');
return PlantingOrder.fromJson(data);
}
throw Exception('确认省市失败: ${response.statusCode}');
} catch (e) {
debugPrint('[PlantingService] 确认省市失败: $e');
rethrow;
}
}
///
///
/// [orderNo]
///
Future<PlantingOrder> payOrder(String orderNo) async {
try {
debugPrint('[PlantingService] 支付订单: orderNo=$orderNo');
final response = await _apiClient.post(
'/planting/orders/$orderNo/pay',
);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
debugPrint('[PlantingService] 支付成功');
return PlantingOrder.fromJson(data);
}
throw Exception('支付失败: ${response.statusCode}');
} catch (e) {
debugPrint('[PlantingService] 支付失败: $e');
rethrow;
}
}
///
///
/// [orderNo]
Future<PlantingOrder> getOrder(String orderNo) async {
try {
debugPrint('[PlantingService] 获取订单详情: orderNo=$orderNo');
final response = await _apiClient.get('/planting/orders/$orderNo');
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
return PlantingOrder.fromJson(data);
}
throw Exception('获取订单失败: ${response.statusCode}');
} catch (e) {
debugPrint('[PlantingService] 获取订单失败: $e');
rethrow;
}
}
///
///
/// [page]
/// [pageSize]
Future<List<PlantingOrder>> getMyOrders({int page = 1, int pageSize = 20}) async {
try {
debugPrint('[PlantingService] 获取我的订单列表: page=$page, pageSize=$pageSize');
final response = await _apiClient.get(
'/planting/orders',
queryParameters: {
'page': page,
'pageSize': pageSize,
},
);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
final items = data['items'] as List<dynamic>? ?? [];
return items.map((e) => PlantingOrder.fromJson(e)).toList();
}
throw Exception('获取订单列表失败: ${response.statusCode}');
} catch (e) {
debugPrint('[PlantingService] 获取订单列表失败: $e');
rethrow;
}
}
///
///
/// [orderNo]
///
Future<void> cancelOrder(String orderNo) async {
try {
debugPrint('[PlantingService] 取消订单: orderNo=$orderNo');
final response = await _apiClient.post(
'/planting/orders/$orderNo/cancel',
);
if (response.statusCode == 200) {
debugPrint('[PlantingService] 订单取消成功');
return;
}
throw Exception('取消订单失败: ${response.statusCode}');
} catch (e) {
debugPrint('[PlantingService] 取消订单失败: $e');
rethrow;
}
}
}

View File

@ -3,15 +3,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:city_pickers/city_pickers.dart';
import '../widgets/planting_confirm_dialog.dart';
import '../../../../core/di/injection_container.dart';
///
class PlantingLocationParams {
final int quantity;
final double totalPrice;
final String orderNo; //
PlantingLocationParams({
required this.quantity,
required this.totalPrice,
required this.orderNo,
});
}
@ -20,11 +23,13 @@ class PlantingLocationParams {
class PlantingLocationPage extends ConsumerStatefulWidget {
final int quantity;
final double totalPrice;
final String orderNo;
const PlantingLocationPage({
super.key,
required this.quantity,
required this.totalPrice,
required this.orderNo,
});
@override
@ -33,11 +38,17 @@ class PlantingLocationPage extends ConsumerStatefulWidget {
}
class _PlantingLocationPageState extends ConsumerState<PlantingLocationPage> {
///
String? _selectedProvince;
///
String? _selectedProvinceName;
///
String? _selectedCity;
///
String? _selectedCityName;
///
String? _selectedProvinceCode;
///
String? _selectedCityCode;
///
bool _isSubmitting = false;
@ -66,8 +77,10 @@ class _PlantingLocationPageState extends ConsumerState<PlantingLocationPage> {
if (result != null) {
setState(() {
_selectedProvince = result.provinceName;
_selectedCity = result.cityName;
_selectedProvinceName = result.provinceName;
_selectedCityName = result.cityName;
_selectedProvinceCode = result.provinceId;
_selectedCityCode = result.cityId;
});
}
}
@ -84,7 +97,7 @@ class _PlantingLocationPageState extends ConsumerState<PlantingLocationPage> {
///
void _confirmSelection() async {
if (_selectedProvince == null || _selectedCity == null) {
if (_selectedProvinceName == null || _selectedCityName == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请选择省份和城市'),
@ -97,8 +110,8 @@ class _PlantingLocationPageState extends ConsumerState<PlantingLocationPage> {
// 5
await PlantingConfirmDialog.show(
context: context,
province: _selectedProvince!,
city: _selectedCity!,
province: _selectedProvinceName!,
city: _selectedCityName!,
onConfirm: _submitPlanting,
);
}
@ -108,8 +121,20 @@ class _PlantingLocationPageState extends ConsumerState<PlantingLocationPage> {
setState(() => _isSubmitting = true);
try {
// TODO: API
await Future.delayed(const Duration(seconds: 1));
final plantingService = ref.read(plantingServiceProvider);
// 1.
await plantingService.selectProvinceCity(
widget.orderNo,
_selectedProvinceCode!,
_selectedCityCode!,
);
// 2.
await plantingService.confirmProvinceCity(widget.orderNo);
// 3.
await plantingService.payOrder(widget.orderNo);
if (mounted) {
//
@ -142,7 +167,7 @@ class _PlantingLocationPageState extends ConsumerState<PlantingLocationPage> {
///
bool get _canSubmit =>
_selectedProvince != null && _selectedCity != null && !_isSubmitting;
_selectedProvinceName != null && _selectedCityName != null && !_isSubmitting;
@override
Widget build(BuildContext context) {
@ -305,12 +330,12 @@ class _PlantingLocationPageState extends ConsumerState<PlantingLocationPage> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_selectedProvince ?? '选择省份',
_selectedProvinceName ?? '选择省份',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
height: 1.5,
color: _selectedProvince != null
color: _selectedProvinceName != null
? const Color(0xFF5D4037)
: const Color(0xFF5D4037).withValues(alpha: 0.5),
),
@ -363,12 +388,12 @@ class _PlantingLocationPageState extends ConsumerState<PlantingLocationPage> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_selectedCity ?? '选择市级',
_selectedCityName ?? '选择市级',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
height: 1.5,
color: _selectedCity != null
color: _selectedCityName != null
? const Color(0xFF5D4037)
: const Color(0xFF5D4037).withValues(alpha: 0.5),
),

View File

@ -28,6 +28,9 @@ class _PlantingQuantityPageState extends ConsumerState<PlantingQuantityPage> {
///
bool _isLoading = false;
///
bool _isCreatingOrder = false;
///
String? _errorMessage;
@ -108,16 +111,44 @@ class _PlantingQuantityPageState extends ConsumerState<PlantingQuantityPage> {
context.pop();
}
///
void _goToNextStep() {
if (_quantity > 0 && _quantity <= _maxQuantity) {
context.push(
RoutePaths.plantingLocation,
extra: PlantingLocationParams(
quantity: _quantity,
totalPrice: _quantity * _pricePerTree,
),
);
///
Future<void> _goToNextStep() async {
if (_quantity <= 0 || _quantity > _maxQuantity || _isCreatingOrder) {
return;
}
setState(() => _isCreatingOrder = true);
try {
// 1. API
final plantingService = ref.read(plantingServiceProvider);
final response = await plantingService.createOrder(_quantity);
if (mounted) {
// 2.
context.push(
RoutePaths.plantingLocation,
extra: PlantingLocationParams(
quantity: _quantity,
totalPrice: _quantity * _pricePerTree,
orderNo: response.orderNo,
),
);
}
} catch (e) {
debugPrint('创建订单失败: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('创建订单失败: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isCreatingOrder = false);
}
}
}
@ -518,7 +549,7 @@ class _PlantingQuantityPageState extends ConsumerState<PlantingQuantityPage> {
///
Widget _buildBottomButton() {
final bool canProceed = _quantity > 0 && _quantity <= _maxQuantity && !_isLoading;
final bool canProceed = _quantity > 0 && _quantity <= _maxQuantity && !_isLoading && !_isCreatingOrder;
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
@ -547,17 +578,26 @@ class _PlantingQuantityPageState extends ConsumerState<PlantingQuantityPage> {
]
: null,
),
child: const Center(
child: Text(
'下一步:选择省市',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.56,
color: Colors.white,
),
),
child: Center(
child: _isCreatingOrder
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'下一步:选择省市',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.56,
color: Colors.white,
),
),
),
),
),