feat: harden phase one workflows
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,3 +22,4 @@ backend/logs/
|
||||
deploy/.data/
|
||||
miniapp/miniprogram_npm/
|
||||
miniapp/private.*
|
||||
**/project.private.config.json
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildGroupBuyOrderActionPath,
|
||||
buildGroupBuyOrderQuery,
|
||||
filterPendingDeliveryGroupBuyOrders,
|
||||
groupBuyOrderDispatchLabel
|
||||
} from './groupBuyApi'
|
||||
@@ -30,6 +31,18 @@ describe('group buy dispatch helpers', () => {
|
||||
expect(buildGroupBuyOrderActionPath(1, 'confirm')).toBe('/api/admin/group-buy-orders/1/confirm')
|
||||
expect(buildGroupBuyOrderActionPath(1, 'ready')).toBe('/api/admin/group-buy-orders/1/ready')
|
||||
})
|
||||
|
||||
it('builds group buy order page query params', () => {
|
||||
expect(buildGroupBuyOrderQuery({
|
||||
status: 'PENDING_CONFIRM',
|
||||
pageNo: 2,
|
||||
pageSize: 10
|
||||
})).toEqual({
|
||||
status: 'PENDING_CONFIRM',
|
||||
pageNo: 2,
|
||||
pageSize: 10
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function groupBuyOrder(overrides: Partial<GroupBuyOrder>): GroupBuyOrder {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GroupBuy, GroupBuyOrder } from '../types'
|
||||
import type { GroupBuy, GroupBuyOrder, PageResponse } from '../types'
|
||||
import { apiGet, apiPost, apiPut } from './http'
|
||||
|
||||
export function listGroupBuys() {
|
||||
@@ -9,6 +9,24 @@ export function listGroupBuyOrders(groupBuyId: number) {
|
||||
return apiGet<GroupBuyOrder[]>(`/api/admin/group-buys/${groupBuyId}/orders`)
|
||||
}
|
||||
|
||||
export interface GroupBuyOrderQuery {
|
||||
status?: string
|
||||
pageNo?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export function buildGroupBuyOrderQuery(query: GroupBuyOrderQuery = {}) {
|
||||
const params: Record<string, string | number> = {}
|
||||
if (query.status) params.status = query.status
|
||||
if (query.pageNo) params.pageNo = query.pageNo
|
||||
if (query.pageSize) params.pageSize = query.pageSize
|
||||
return params
|
||||
}
|
||||
|
||||
export function pageGroupBuyOrders(groupBuyId: number, query: GroupBuyOrderQuery = {}) {
|
||||
return apiGet<PageResponse<GroupBuyOrder>>(`/api/admin/group-buys/${groupBuyId}/orders/page`, buildGroupBuyOrderQuery(query))
|
||||
}
|
||||
|
||||
export function filterPendingDeliveryGroupBuyOrders(orders: GroupBuyOrder[]) {
|
||||
return orders.filter((order) => order.status === 'PENDING_DELIVERY')
|
||||
}
|
||||
|
||||
@@ -6,11 +6,15 @@ describe('buildGoodsOrderQuery', () => {
|
||||
expect(buildGoodsOrderQuery({
|
||||
status: 'PENDING_DELIVERY',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-07'
|
||||
endDate: '2026-07-07',
|
||||
pageNo: 2,
|
||||
pageSize: 20
|
||||
})).toEqual({
|
||||
status: 'PENDING_DELIVERY',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-07'
|
||||
endDate: '2026-07-07',
|
||||
pageNo: 2,
|
||||
pageSize: 20
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import type { ExpressOrder, GoodsOrder } from '../types'
|
||||
import type { ExpressOrder, GoodsOrder, PageResponse } from '../types'
|
||||
import { apiGet, apiPut } from './http'
|
||||
|
||||
export interface GoodsOrderQuery {
|
||||
status?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
pageNo?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export function buildGoodsOrderQuery(query: GoodsOrderQuery) {
|
||||
const params: Record<string, string> = {}
|
||||
const params: Record<string, string | number> = {}
|
||||
if (query.status) params.status = query.status
|
||||
if (query.startDate) params.startDate = query.startDate
|
||||
if (query.endDate) params.endDate = query.endDate
|
||||
if (query.pageNo) params.pageNo = query.pageNo
|
||||
if (query.pageSize) params.pageSize = query.pageSize
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -19,6 +23,10 @@ export function listGoodsOrders(query: GoodsOrderQuery = {}) {
|
||||
return apiGet<GoodsOrder[]>('/api/admin/goods-orders', buildGoodsOrderQuery(query))
|
||||
}
|
||||
|
||||
export function pageGoodsOrders(query: GoodsOrderQuery = {}) {
|
||||
return apiGet<PageResponse<GoodsOrder>>('/api/admin/goods-orders/page', buildGoodsOrderQuery(query))
|
||||
}
|
||||
|
||||
export function confirmGoodsOrder(id: number) {
|
||||
return apiPut<GoodsOrder>(`/api/admin/goods-orders/${id}/confirm`)
|
||||
}
|
||||
@@ -28,17 +36,35 @@ export function markGoodsOrderPrepared(id: number) {
|
||||
}
|
||||
|
||||
export function cancelGoodsOrder(id: number) {
|
||||
return apiPut<GoodsOrder>(`/api/mini/goods-orders/${id}/cancel`)
|
||||
return apiPut<GoodsOrder>(`/api/admin/goods-orders/${id}/cancel`)
|
||||
}
|
||||
|
||||
export function markGoodsOrderPaid(id: number) {
|
||||
return apiPut<GoodsOrder>(`/api/admin/goods-orders/${id}/paid`)
|
||||
}
|
||||
|
||||
export interface ExpressOrderQuery {
|
||||
status?: string
|
||||
pageNo?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export function buildExpressOrderQuery(query: ExpressOrderQuery = {}) {
|
||||
const params: Record<string, string | number> = {}
|
||||
if (query.status) params.status = query.status
|
||||
if (query.pageNo) params.pageNo = query.pageNo
|
||||
if (query.pageSize) params.pageSize = query.pageSize
|
||||
return params
|
||||
}
|
||||
|
||||
export function listExpressOrders(status?: string) {
|
||||
return apiGet<ExpressOrder[]>('/api/admin/express-orders', status ? { status } : undefined)
|
||||
}
|
||||
|
||||
export function pageExpressOrders(query: ExpressOrderQuery = {}) {
|
||||
return apiGet<PageResponse<ExpressOrder>>('/api/admin/express-orders/page', buildExpressOrderQuery(query))
|
||||
}
|
||||
|
||||
export function confirmExpressOrder(id: number) {
|
||||
return apiPut<ExpressOrder>(`/api/admin/express-orders/${id}/confirm`)
|
||||
}
|
||||
|
||||
20
admin-web/src/api/productApi.test.ts
Normal file
20
admin-web/src/api/productApi.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildProductQuery } from './productApi'
|
||||
|
||||
describe('buildProductQuery', () => {
|
||||
it('keeps pagination and optional product filters stable', () => {
|
||||
expect(buildProductQuery({
|
||||
keyword: '苹果',
|
||||
categoryId: 2,
|
||||
merchantId: 1,
|
||||
pageNo: 3,
|
||||
pageSize: 10
|
||||
})).toEqual({
|
||||
keyword: '苹果',
|
||||
categoryId: 2,
|
||||
merchantId: 1,
|
||||
pageNo: 3,
|
||||
pageSize: 10
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,28 @@
|
||||
import type { Product, ProductCategory, ProductSku } from '../types'
|
||||
import type { PageResponse, Product, ProductCategory, ProductSku } from '../types'
|
||||
import { apiGet, apiPost, apiPut } from './http'
|
||||
|
||||
export function listProducts() {
|
||||
return apiGet<Product[]>('/api/admin/products')
|
||||
export interface ProductQuery {
|
||||
keyword?: string
|
||||
categoryId?: number
|
||||
merchantId?: number
|
||||
pageNo?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export function buildProductQuery(query: ProductQuery = {}) {
|
||||
const params: Record<string, string | number> = {
|
||||
pageNo: query.pageNo || 1,
|
||||
pageSize: query.pageSize || 20
|
||||
}
|
||||
if (query.keyword) params.keyword = query.keyword
|
||||
if (query.categoryId) params.categoryId = query.categoryId
|
||||
if (query.merchantId) params.merchantId = query.merchantId
|
||||
return params
|
||||
}
|
||||
|
||||
export async function listProducts(query: ProductQuery = {}) {
|
||||
const page = await apiGet<PageResponse<Product>>('/api/admin/products/page', buildProductQuery(query))
|
||||
return page.records
|
||||
}
|
||||
|
||||
export function createProduct(data: Partial<Product>) {
|
||||
|
||||
@@ -140,3 +140,9 @@ body {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ export interface CurrentUser {
|
||||
roleCode: string
|
||||
}
|
||||
|
||||
export interface PageResponse<T> {
|
||||
total: number
|
||||
pageNo: number
|
||||
pageSize: number
|
||||
records: T[]
|
||||
}
|
||||
|
||||
export interface ProductSku {
|
||||
id: number
|
||||
productId: number
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
adjustExpressFee,
|
||||
cancelExpressOrder,
|
||||
confirmExpressOrder,
|
||||
listExpressOrders,
|
||||
pageExpressOrders,
|
||||
updateExpressPayStatus
|
||||
} from '../api/orderApi'
|
||||
import type { ExpressOrder } from '../types'
|
||||
@@ -15,11 +15,18 @@ import { statusText, yuan } from '../utils/format'
|
||||
const loading = ref(false)
|
||||
const rows = ref<ExpressOrder[]>([])
|
||||
const filters = reactive({ status: '' })
|
||||
const pagination = reactive({ pageNo: 1, pageSize: 10, total: 0 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await listExpressOrders(filters.status || undefined)
|
||||
const page = await pageExpressOrders({
|
||||
status: filters.status || undefined,
|
||||
pageNo: pagination.pageNo,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
rows.value = page.records
|
||||
pagination.total = page.total
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||
} finally {
|
||||
@@ -27,6 +34,17 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
pagination.pageNo = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pagination.pageSize = size
|
||||
pagination.pageNo = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function run(action: () => Promise<ExpressOrder>) {
|
||||
try {
|
||||
await action()
|
||||
@@ -63,7 +81,7 @@ onMounted(load)
|
||||
<el-option label="配送中" value="DELIVERING" />
|
||||
<el-option label="已完成" value="COMPLETED" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="load">查询</el-button>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="search">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="rows" border v-loading="loading">
|
||||
@@ -88,5 +106,16 @@ onMounted(load)
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.pageNo"
|
||||
:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="changeSize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,9 +5,9 @@ import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
cancelGoodsOrder,
|
||||
confirmGoodsOrder,
|
||||
listGoodsOrders,
|
||||
markGoodsOrderPaid,
|
||||
markGoodsOrderPrepared
|
||||
markGoodsOrderPrepared,
|
||||
pageGoodsOrders
|
||||
} from '../api/orderApi'
|
||||
import type { GoodsOrder } from '../types'
|
||||
import { statusText, yuan } from '../utils/format'
|
||||
@@ -15,11 +15,18 @@ import { statusText, yuan } from '../utils/format'
|
||||
const loading = ref(false)
|
||||
const rows = ref<GoodsOrder[]>([])
|
||||
const filters = reactive({ status: '' })
|
||||
const pagination = reactive({ pageNo: 1, pageSize: 10, total: 0 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await listGoodsOrders({ status: filters.status || undefined })
|
||||
const page = await pageGoodsOrders({
|
||||
status: filters.status || undefined,
|
||||
pageNo: pagination.pageNo,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
rows.value = page.records
|
||||
pagination.total = page.total
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||
} finally {
|
||||
@@ -27,6 +34,17 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
pagination.pageNo = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pagination.pageSize = size
|
||||
pagination.pageNo = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function run(action: () => Promise<GoodsOrder>) {
|
||||
try {
|
||||
await action()
|
||||
@@ -51,7 +69,7 @@ onMounted(load)
|
||||
<el-option label="配送中" value="DELIVERING" />
|
||||
<el-option label="已完成" value="COMPLETED" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="load">查询</el-button>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="search">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="rows" border v-loading="loading">
|
||||
@@ -79,5 +97,16 @@ onMounted(load)
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.pageNo"
|
||||
:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="changeSize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
completeGroupBuy,
|
||||
confirmGroupBuyOrder,
|
||||
createGroupBuy,
|
||||
listGroupBuyOrders,
|
||||
listGroupBuys,
|
||||
markGroupBuyOrderReady,
|
||||
pageGroupBuyOrders,
|
||||
startGroupBuy
|
||||
} from '../api/groupBuyApi'
|
||||
import type { GroupBuy, GroupBuyOrder } from '../types'
|
||||
@@ -22,6 +22,7 @@ const orders = ref<GroupBuyOrder[]>([])
|
||||
const activeGroupBuy = ref<GroupBuy | null>(null)
|
||||
const dialog = ref(false)
|
||||
const orderDialog = ref(false)
|
||||
const orderPagination = reactive({ pageNo: 1, pageSize: 10, total: 0 })
|
||||
const form = reactive({
|
||||
title: '',
|
||||
coverUrl: '',
|
||||
@@ -46,6 +47,7 @@ async function load() {
|
||||
|
||||
async function openOrders(row: GroupBuy) {
|
||||
activeGroupBuy.value = row
|
||||
orderPagination.pageNo = 1
|
||||
orderDialog.value = true
|
||||
await loadOrders(row.id)
|
||||
}
|
||||
@@ -54,7 +56,12 @@ async function loadOrders(groupBuyId = activeGroupBuy.value?.id) {
|
||||
if (!groupBuyId) return
|
||||
orderLoading.value = true
|
||||
try {
|
||||
orders.value = await listGroupBuyOrders(groupBuyId)
|
||||
const page = await pageGroupBuyOrders(groupBuyId, {
|
||||
pageNo: orderPagination.pageNo,
|
||||
pageSize: orderPagination.pageSize
|
||||
})
|
||||
orders.value = page.records
|
||||
orderPagination.total = page.total
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||
} finally {
|
||||
@@ -62,6 +69,12 @@ async function loadOrders(groupBuyId = activeGroupBuy.value?.id) {
|
||||
}
|
||||
}
|
||||
|
||||
function changeOrderSize(size: number) {
|
||||
orderPagination.pageSize = size
|
||||
orderPagination.pageNo = 1
|
||||
loadOrders()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await createGroupBuy(form)
|
||||
@@ -153,6 +166,17 @@ onMounted(load)
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="orderPagination.pageNo"
|
||||
:page-size="orderPagination.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="orderPagination.total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="loadOrders()"
|
||||
@size-change="changeOrderSize"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="dialog" title="新建团购" width="560px">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Check, Edit, Plus, Refresh, Upload } from '@element-plus/icons-vue'
|
||||
import { Check, Edit, Plus, Refresh, Search, Upload } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
addSku,
|
||||
@@ -17,6 +17,7 @@ import { statusText, yuan } from '../utils/format'
|
||||
const loading = ref(false)
|
||||
const products = ref<Product[]>([])
|
||||
const categories = ref<ProductCategory[]>([])
|
||||
const keyword = ref('')
|
||||
const productDialog = ref(false)
|
||||
const categoryDialog = ref(false)
|
||||
const skuDialog = ref(false)
|
||||
@@ -28,7 +29,10 @@ const skuForm = reactive({ skuName: '', priceCent: 0, originPriceCent: undefined
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [productRows, categoryRows] = await Promise.all([listProducts(), listProductCategories()])
|
||||
const [productRows, categoryRows] = await Promise.all([
|
||||
listProducts({ keyword: keyword.value.trim() || undefined }),
|
||||
listProductCategories()
|
||||
])
|
||||
products.value = productRows
|
||||
categories.value = categoryRows
|
||||
if (!productForm.categoryId && categoryRows[0]) {
|
||||
@@ -99,6 +103,15 @@ onMounted(load)
|
||||
<h1 class="page-title">商品管理</h1>
|
||||
<div class="panel">
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="搜索商品"
|
||||
style="width: 220px"
|
||||
@keyup.enter="load"
|
||||
@clear="load"
|
||||
/>
|
||||
<el-button :icon="Search" :loading="loading" @click="load">搜索</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="productDialog = true">新建商品</el-button>
|
||||
<el-button :icon="Plus" @click="categoryDialog = true">新建分类</el-button>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||
|
||||
22
backend/src/main/java/com/linhelp/common/api/PageUtils.java
Normal file
22
backend/src/main/java/com/linhelp/common/api/PageUtils.java
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.linhelp.common.api;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class PageUtils {
|
||||
private static final int DEFAULT_PAGE_NO = 1;
|
||||
private static final int DEFAULT_PAGE_SIZE = 20;
|
||||
private static final int MAX_PAGE_SIZE = 100;
|
||||
|
||||
private PageUtils() {
|
||||
}
|
||||
|
||||
public static <T> PageResponse<T> page(List<T> records, Integer pageNo, Integer pageSize) {
|
||||
int safePageNo = pageNo == null || pageNo < 1 ? DEFAULT_PAGE_NO : pageNo;
|
||||
int safePageSize = pageSize == null || pageSize < 1 ? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE);
|
||||
int total = records.size();
|
||||
int fromIndex = Math.min((safePageNo - 1) * safePageSize, total);
|
||||
int toIndex = Math.min(fromIndex + safePageSize, total);
|
||||
return new PageResponse<T>(total, safePageNo, safePageSize, new ArrayList<T>(records.subList(fromIndex, toIndex)));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,11 @@ public class AuthController {
|
||||
return ApiResponse.ok(authService.devMiniappLogin(request));
|
||||
}
|
||||
|
||||
@PostMapping("/miniapp-login")
|
||||
public ApiResponse<LoginResponse> miniappLogin(@Valid @RequestBody MiniappLoginRequest request) {
|
||||
return ApiResponse.ok(authService.miniappLogin(request));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ApiResponse<Void> logout() {
|
||||
authService.logout();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.linhelp.common.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class AuthProperties {
|
||||
private final boolean demoAdminEnabled;
|
||||
private final boolean devMiniappLoginEnabled;
|
||||
|
||||
public AuthProperties(@Value("${linhelp.auth.demo-admin-enabled:false}") boolean demoAdminEnabled,
|
||||
@Value("${linhelp.auth.dev-miniapp-login-enabled:false}") boolean devMiniappLoginEnabled) {
|
||||
this.demoAdminEnabled = demoAdminEnabled;
|
||||
this.devMiniappLoginEnabled = devMiniappLoginEnabled;
|
||||
}
|
||||
|
||||
public boolean isDemoAdminEnabled() {
|
||||
return demoAdminEnabled;
|
||||
}
|
||||
|
||||
public boolean isDevMiniappLoginEnabled() {
|
||||
return devMiniappLoginEnabled;
|
||||
}
|
||||
}
|
||||
@@ -2,35 +2,119 @@ package com.linhelp.common.security;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.linhelp.common.exception.BizException;
|
||||
import com.linhelp.common.security.account.AdminAccount;
|
||||
import com.linhelp.common.security.account.AdminAccountRepository;
|
||||
import com.linhelp.common.security.account.PasswordHasher;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Service
|
||||
public class AuthService {
|
||||
private static final Long DEFAULT_TENANT_ID = 1L;
|
||||
private static final Long DEFAULT_COMMUNITY_ID = 1L;
|
||||
private final Map<String, DemoAccount> demoAccounts = new HashMap<String, DemoAccount>();
|
||||
private final Map<String, Long> miniappUsers = new HashMap<String, Long>();
|
||||
private final AtomicLong miniappUserIds = new AtomicLong(1000L);
|
||||
private final MiniappSessionClient miniappSessionClient;
|
||||
private final LoginSessionIssuer loginSessionIssuer;
|
||||
private final boolean demoAdminEnabled;
|
||||
private final boolean devMiniappLoginEnabled;
|
||||
private final AdminAccountRepository adminAccountRepository;
|
||||
private final PasswordHasher passwordHasher;
|
||||
|
||||
public AuthService() {
|
||||
@Autowired
|
||||
public AuthService(MiniappSessionClient miniappSessionClient,
|
||||
LoginSessionIssuer loginSessionIssuer,
|
||||
AuthProperties authProperties,
|
||||
ObjectProvider<AdminAccountRepository> adminAccountRepositoryProvider,
|
||||
PasswordHasher passwordHasher) {
|
||||
this(miniappSessionClient,
|
||||
loginSessionIssuer,
|
||||
authProperties.isDemoAdminEnabled(),
|
||||
authProperties.isDevMiniappLoginEnabled(),
|
||||
adminAccountRepositoryProvider.getIfAvailable(),
|
||||
passwordHasher);
|
||||
}
|
||||
|
||||
protected AuthService() {
|
||||
this(new MiniappSessionClient() {
|
||||
@Override
|
||||
public String resolveOpenId(String code) {
|
||||
return "test-openid";
|
||||
}
|
||||
}, new SaTokenLoginSessionIssuer(), true, true, null, new PasswordHasher());
|
||||
}
|
||||
|
||||
public AuthService(MiniappSessionClient miniappSessionClient,
|
||||
LoginSessionIssuer loginSessionIssuer,
|
||||
boolean demoAdminEnabled,
|
||||
boolean devMiniappLoginEnabled) {
|
||||
this(miniappSessionClient, loginSessionIssuer, demoAdminEnabled, devMiniappLoginEnabled, null, new PasswordHasher());
|
||||
}
|
||||
|
||||
public AuthService(MiniappSessionClient miniappSessionClient,
|
||||
LoginSessionIssuer loginSessionIssuer,
|
||||
boolean demoAdminEnabled,
|
||||
boolean devMiniappLoginEnabled,
|
||||
AdminAccountRepository adminAccountRepository,
|
||||
PasswordHasher passwordHasher) {
|
||||
this.miniappSessionClient = miniappSessionClient;
|
||||
this.loginSessionIssuer = loginSessionIssuer;
|
||||
this.demoAdminEnabled = demoAdminEnabled;
|
||||
this.devMiniappLoginEnabled = devMiniappLoginEnabled;
|
||||
this.adminAccountRepository = adminAccountRepository;
|
||||
this.passwordHasher = passwordHasher;
|
||||
demoAccounts.put("admin", new DemoAccount(1L, "admin123", "COMMUNITY_ADMIN"));
|
||||
demoAccounts.put("merchant", new DemoAccount(2L, "merchant123", "MERCHANT"));
|
||||
demoAccounts.put("rider", new DemoAccount(3L, "rider123", "RIDER"));
|
||||
}
|
||||
|
||||
public LoginResponse adminLogin(LoginRequest request) {
|
||||
Optional<AdminAccount> storedAccount = findStoredAccount(request.getUsername());
|
||||
if (storedAccount.isPresent()) {
|
||||
AdminAccount account = storedAccount.get();
|
||||
if (!account.isEnabled()) {
|
||||
throw new BizException(401, "admin account disabled");
|
||||
}
|
||||
if (!passwordHasher.matches(request.getPassword(), account.getPasswordHash())) {
|
||||
throw new BizException(401, "username or password is incorrect");
|
||||
}
|
||||
return login(account.getUserId(), account.getTenantId(), account.getCommunityId(), account.getRoleCode());
|
||||
}
|
||||
|
||||
if (!demoAdminEnabled) {
|
||||
throw new BizException("demo admin login is disabled");
|
||||
}
|
||||
DemoAccount account = demoAccounts.get(request.getUsername());
|
||||
if (account == null || !account.password.equals(request.getPassword())) {
|
||||
throw new BizException(401, "账号或密码错误");
|
||||
throw new BizException(401, "username or password is incorrect");
|
||||
}
|
||||
return login(account.userId, account.roleCode);
|
||||
}
|
||||
|
||||
public LoginResponse devMiniappLogin(LoginRequest request) {
|
||||
if (!devMiniappLoginEnabled) {
|
||||
throw new BizException("dev miniapp login is disabled");
|
||||
}
|
||||
return login(1000L, "RESIDENT");
|
||||
}
|
||||
|
||||
public synchronized LoginResponse miniappLogin(MiniappLoginRequest request) {
|
||||
String openId = miniappSessionClient.resolveOpenId(request.getCode());
|
||||
Long userId = miniappUsers.get(openId);
|
||||
if (userId == null) {
|
||||
userId = miniappUserIds.getAndIncrement();
|
||||
miniappUsers.put(openId, userId);
|
||||
}
|
||||
return login(userId, "RESIDENT");
|
||||
}
|
||||
|
||||
public CurrentUser currentUser() {
|
||||
StpUtil.checkLogin();
|
||||
return new CurrentUser(
|
||||
@@ -46,11 +130,18 @@ public class AuthService {
|
||||
}
|
||||
|
||||
private LoginResponse login(Long userId, String roleCode) {
|
||||
StpUtil.login(userId);
|
||||
StpUtil.getSession().set("tenantId", DEFAULT_TENANT_ID);
|
||||
StpUtil.getSession().set("communityId", DEFAULT_COMMUNITY_ID);
|
||||
StpUtil.getSession().set("roleCode", roleCode);
|
||||
return new LoginResponse(StpUtil.getTokenValue(), userId, DEFAULT_TENANT_ID, DEFAULT_COMMUNITY_ID, roleCode);
|
||||
return login(userId, DEFAULT_TENANT_ID, DEFAULT_COMMUNITY_ID, roleCode);
|
||||
}
|
||||
|
||||
private LoginResponse login(Long userId, Long tenantId, Long communityId, String roleCode) {
|
||||
return loginSessionIssuer.issue(userId, tenantId, communityId, roleCode);
|
||||
}
|
||||
|
||||
private Optional<AdminAccount> findStoredAccount(String username) {
|
||||
if (adminAccountRepository == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return adminAccountRepository.findByUsername(username);
|
||||
}
|
||||
|
||||
private static class DemoAccount {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.linhelp.common.security;
|
||||
|
||||
public interface LoginSessionIssuer {
|
||||
LoginResponse issue(Long userId, Long tenantId, Long communityId, String roleCode);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.linhelp.common.security;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
public class MiniappLoginRequest {
|
||||
@NotBlank(message = "登录凭证不能为空")
|
||||
private String code;
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.linhelp.common.security;
|
||||
|
||||
public interface MiniappSessionClient {
|
||||
String resolveOpenId(String code);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.linhelp.common.security;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class SaTokenLoginSessionIssuer implements LoginSessionIssuer {
|
||||
@Override
|
||||
public LoginResponse issue(Long userId, Long tenantId, Long communityId, String roleCode) {
|
||||
StpUtil.login(userId);
|
||||
StpUtil.getSession().set("tenantId", tenantId);
|
||||
StpUtil.getSession().set("communityId", communityId);
|
||||
StpUtil.getSession().set("roleCode", roleCode);
|
||||
return new LoginResponse(StpUtil.getTokenValue(), userId, tenantId, communityId, roleCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.linhelp.common.security;
|
||||
|
||||
import com.linhelp.common.exception.BizException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class WechatMiniappSessionClient implements MiniappSessionClient {
|
||||
private static final String JSCODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
|
||||
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
private final String appId;
|
||||
private final String appSecret;
|
||||
|
||||
public WechatMiniappSessionClient(@Value("${linhelp.wechat.miniapp.app-id:}") String appId,
|
||||
@Value("${linhelp.wechat.miniapp.app-secret:}") String appSecret) {
|
||||
this.appId = appId;
|
||||
this.appSecret = appSecret;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String resolveOpenId(String code) {
|
||||
if (isBlank(appId) || isBlank(appSecret)) {
|
||||
throw new BizException("微信小程序配置未完成");
|
||||
}
|
||||
String url = UriComponentsBuilder.fromHttpUrl(JSCODE2SESSION_URL)
|
||||
.queryParam("appid", appId)
|
||||
.queryParam("secret", appSecret)
|
||||
.queryParam("js_code", code)
|
||||
.queryParam("grant_type", "authorization_code")
|
||||
.toUriString();
|
||||
Map response = restTemplate.getForObject(url, Map.class);
|
||||
if (response == null || response.get("openid") == null) {
|
||||
Object message = response == null ? null : response.get("errmsg");
|
||||
throw new BizException(message == null ? "微信登录失败" : String.valueOf(message));
|
||||
}
|
||||
return String.valueOf(response.get("openid"));
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.linhelp.common.security.account;
|
||||
|
||||
public class AdminAccount {
|
||||
private final Long userId;
|
||||
private final Long tenantId;
|
||||
private final Long communityId;
|
||||
private final String username;
|
||||
private final String passwordHash;
|
||||
private final String roleCode;
|
||||
private final boolean enabled;
|
||||
|
||||
public AdminAccount(Long userId,
|
||||
Long tenantId,
|
||||
Long communityId,
|
||||
String username,
|
||||
String passwordHash,
|
||||
String roleCode,
|
||||
boolean enabled) {
|
||||
this.userId = userId;
|
||||
this.tenantId = tenantId;
|
||||
this.communityId = communityId;
|
||||
this.username = username;
|
||||
this.passwordHash = passwordHash;
|
||||
this.roleCode = roleCode;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public Long getTenantId() {
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
public Long getCommunityId() {
|
||||
return communityId;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
public String getRoleCode() {
|
||||
return roleCode;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.linhelp.common.security.account;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AdminAccountRepository {
|
||||
Optional<AdminAccount> findByUsername(String username);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.linhelp.common.security.account;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
@ConditionalOnBean(JdbcTemplate.class)
|
||||
public class JdbcAdminAccountRepository implements AdminAccountRepository {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public JdbcAdminAccountRepository(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AdminAccount> findByUsername(String username) {
|
||||
try {
|
||||
return Optional.of(jdbcTemplate.queryForObject(
|
||||
"SELECT user_id, tenant_id, community_id, username, password_hash, role_code, enabled " +
|
||||
"FROM admin_user WHERE username = ? AND deleted = 0",
|
||||
this::mapAccount,
|
||||
username
|
||||
));
|
||||
} catch (EmptyResultDataAccessException ex) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private AdminAccount mapAccount(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new AdminAccount(
|
||||
rs.getLong("user_id"),
|
||||
rs.getLong("tenant_id"),
|
||||
rs.getLong("community_id"),
|
||||
rs.getString("username"),
|
||||
rs.getString("password_hash"),
|
||||
rs.getString("role_code"),
|
||||
rs.getBoolean("enabled")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.linhelp.common.security.account;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
@Component
|
||||
public class PasswordHasher {
|
||||
private static final String ALGORITHM = "PBKDF2WithHmacSHA256";
|
||||
private static final String PREFIX = "pbkdf2_sha256";
|
||||
private static final int ITERATIONS = 60000;
|
||||
private static final int SALT_BYTES = 16;
|
||||
private static final int HASH_BITS = 256;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public String hash(String password) {
|
||||
byte[] salt = new byte[SALT_BYTES];
|
||||
secureRandom.nextBytes(salt);
|
||||
byte[] hash = pbkdf2(password, salt, ITERATIONS);
|
||||
return PREFIX + "$" + ITERATIONS + "$" + encode(salt) + "$" + encode(hash);
|
||||
}
|
||||
|
||||
public boolean matches(String password, String encoded) {
|
||||
if (password == null || encoded == null) {
|
||||
return false;
|
||||
}
|
||||
String[] parts = encoded.split("\\$");
|
||||
if (parts.length != 4 || !PREFIX.equals(parts[0])) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
int iterations = Integer.parseInt(parts[1]);
|
||||
byte[] salt = decode(parts[2]);
|
||||
byte[] expected = decode(parts[3]);
|
||||
byte[] actual = pbkdf2(password, salt, iterations);
|
||||
return MessageDigest.isEqual(expected, actual);
|
||||
} catch (RuntimeException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] pbkdf2(String password, byte[] salt, int iterations) {
|
||||
try {
|
||||
PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, HASH_BITS);
|
||||
SecretKeyFactory factory = SecretKeyFactory.getInstance(ALGORITHM);
|
||||
return factory.generateSecret(spec).getEncoded();
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("Could not hash password", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String encode(byte[] value) {
|
||||
return Base64.getEncoder().encodeToString(value);
|
||||
}
|
||||
|
||||
private byte[] decode(String value) {
|
||||
return Base64.getDecoder().decode(value);
|
||||
}
|
||||
}
|
||||
@@ -65,12 +65,14 @@ public class DeliveryAdminController {
|
||||
|
||||
@PostMapping("/api/admin/delivery-orders/assign-goods-order")
|
||||
public ApiResponse<DeliveryOrderResponse> assignGoodsOrder(@Valid @RequestBody DeliveryAssignRequest request) {
|
||||
return ApiResponse.ok(assignmentService.assignGoodsOrder(request.getOrderId(), request.getRiderId()));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(assignmentService.assignGoodsOrder(user.getCommunityId(), request.getOrderId(), request.getRiderId()));
|
||||
}
|
||||
|
||||
@PostMapping("/api/admin/delivery-orders/assign-express-order")
|
||||
public ApiResponse<DeliveryOrderResponse> assignExpressOrder(@Valid @RequestBody DeliveryAssignRequest request) {
|
||||
return ApiResponse.ok(assignmentService.assignExpressOrder(request.getOrderId(), request.getRiderId()));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(assignmentService.assignExpressOrder(user.getCommunityId(), request.getOrderId(), request.getRiderId()));
|
||||
}
|
||||
|
||||
@PostMapping("/api/admin/delivery-orders/assign-group-buy-order")
|
||||
@@ -87,6 +89,7 @@ public class DeliveryAdminController {
|
||||
|
||||
@GetMapping("/api/admin/delivery-orders/{id}")
|
||||
public ApiResponse<DeliveryOrderResponse> taskDetail(@PathVariable Long id) {
|
||||
return ApiResponse.ok(assignmentService.detail(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(assignmentService.detailAdmin(user.getCommunityId(), id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,14 @@ public class DeliveryAssignmentService {
|
||||
}
|
||||
|
||||
public synchronized DeliveryOrderResponse assignGoodsOrder(Long goodsOrderId, Long riderId) {
|
||||
DeliveryUserResponse rider = deliveryUserService.requireEnabled(riderId);
|
||||
GoodsOrderResponse order = goodsOrderService.detail(goodsOrderId);
|
||||
return assignGoodsOrder(order.getCommunityId(), goodsOrderId, riderId);
|
||||
}
|
||||
|
||||
public synchronized DeliveryOrderResponse assignGoodsOrder(Long communityId, Long goodsOrderId, Long riderId) {
|
||||
DeliveryUserResponse rider = deliveryUserService.requireEnabled(riderId);
|
||||
GoodsOrderResponse order = goodsOrderService.detailAdmin(communityId, goodsOrderId);
|
||||
requireRiderInCommunity(rider, order.getCommunityId());
|
||||
if (DeliveryMethod.SELF_PICKUP.equals(order.getDeliveryMethod())) {
|
||||
throw new BizException("自提订单无需派单");
|
||||
}
|
||||
@@ -74,8 +80,17 @@ public class DeliveryAssignmentService {
|
||||
if (expressOrderService == null) {
|
||||
throw new BizException("快递代取服务未启用");
|
||||
}
|
||||
DeliveryUserResponse rider = deliveryUserService.requireEnabled(riderId);
|
||||
ExpressOrderResponse order = expressOrderService.detail(expressOrderId);
|
||||
return assignExpressOrder(order.getCommunityId(), expressOrderId, riderId);
|
||||
}
|
||||
|
||||
public synchronized DeliveryOrderResponse assignExpressOrder(Long communityId, Long expressOrderId, Long riderId) {
|
||||
if (expressOrderService == null) {
|
||||
throw new BizException("快递代取服务未启用");
|
||||
}
|
||||
DeliveryUserResponse rider = deliveryUserService.requireEnabled(riderId);
|
||||
ExpressOrderResponse order = expressOrderService.detailAdmin(communityId, expressOrderId);
|
||||
requireRiderInCommunity(rider, order.getCommunityId());
|
||||
if (ExpressOrderStatus.PENDING_PICKUP != order.getStatus()) {
|
||||
throw new BizException("快递订单未待取件");
|
||||
}
|
||||
@@ -95,10 +110,8 @@ public class DeliveryAssignmentService {
|
||||
|
||||
public synchronized DeliveryOrderResponse assignGroupBuyOrder(Long communityId, Long groupBuyOrderId, Long riderId) {
|
||||
DeliveryUserResponse rider = deliveryUserService.requireEnabled(riderId);
|
||||
GroupBuyOrderResponse order = groupBuyOrderService.detail(groupBuyOrderId);
|
||||
if (!communityId.equals(order.getCommunityId())) {
|
||||
throw new BizException(404, "团购订单不存在");
|
||||
}
|
||||
GroupBuyOrderResponse order = groupBuyOrderService.detailAdmin(communityId, groupBuyOrderId);
|
||||
requireRiderInCommunity(rider, order.getCommunityId());
|
||||
if (DeliveryMethod.SELF_PICKUP.equals(order.getDeliveryMethod())) {
|
||||
throw new BizException("自提订单无需派单");
|
||||
}
|
||||
@@ -135,6 +148,14 @@ public class DeliveryAssignmentService {
|
||||
return copy(require(id));
|
||||
}
|
||||
|
||||
public synchronized DeliveryOrderResponse detailAdmin(Long communityId, Long id) {
|
||||
DeliveryOrderResponse task = require(id);
|
||||
if (!communityId.equals(task.getCommunityId())) {
|
||||
throw new BizException(404, "配送任务不存在");
|
||||
}
|
||||
return copy(task);
|
||||
}
|
||||
|
||||
public synchronized List<DeliveryOrderResponse> listRiderTasks(Long riderId) {
|
||||
List<DeliveryOrderResponse> result = new ArrayList<DeliveryOrderResponse>();
|
||||
for (DeliveryOrderResponse task : tasks.values()) {
|
||||
@@ -182,6 +203,12 @@ public class DeliveryAssignmentService {
|
||||
}
|
||||
}
|
||||
|
||||
private void requireRiderInCommunity(DeliveryUserResponse rider, Long communityId) {
|
||||
if (!communityId.equals(rider.getCommunityId())) {
|
||||
throw new BizException(404, "配送员不存在");
|
||||
}
|
||||
}
|
||||
|
||||
private void enrichAddressContact(DeliveryOrderResponse task, Long userId, Long addressId) {
|
||||
if (addressId == null) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.linhelp.express;
|
||||
|
||||
import com.linhelp.common.api.ApiResponse;
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.enums.ExpressOrderStatus;
|
||||
import com.linhelp.common.security.AuthService;
|
||||
import com.linhelp.common.security.CurrentUser;
|
||||
@@ -42,17 +43,20 @@ public class ExpressOrderController {
|
||||
|
||||
@GetMapping("/api/mini/express-orders/{id}")
|
||||
public ApiResponse<ExpressOrderResponse> miniDetail(@PathVariable Long id) {
|
||||
return ApiResponse.ok(expressOrderService.detail(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(expressOrderService.detailMine(user.getUserId(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/api/mini/express-orders/{id}/cancel")
|
||||
public ApiResponse<ExpressOrderResponse> miniCancel(@PathVariable Long id, @RequestBody(required = false) ExpressCancelRequest request) {
|
||||
return ApiResponse.ok(expressOrderService.cancel(id, request == null ? null : request.getReason()));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(expressOrderService.cancelMine(user.getUserId(), id, request == null ? null : request.getReason()));
|
||||
}
|
||||
|
||||
@PutMapping("/api/mini/express-orders/{id}/complete")
|
||||
public ApiResponse<ExpressOrderResponse> miniComplete(@PathVariable Long id) {
|
||||
return ApiResponse.ok(expressOrderService.complete(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(expressOrderService.completeMine(user.getUserId(), id));
|
||||
}
|
||||
|
||||
@GetMapping("/api/admin/express-orders")
|
||||
@@ -62,35 +66,49 @@ public class ExpressOrderController {
|
||||
return ApiResponse.ok(expressOrderService.listAdmin(user.getCommunityId(), status));
|
||||
}
|
||||
|
||||
@GetMapping("/api/admin/express-orders/page")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<PageResponse<ExpressOrderResponse>> pageAdmin(@RequestParam(required = false) ExpressOrderStatus status,
|
||||
@RequestParam(required = false) Integer pageNo,
|
||||
@RequestParam(required = false) Integer pageSize) {
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(expressOrderService.listAdminPage(user.getCommunityId(), status, pageNo, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/api/admin/express-orders/{id}")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<ExpressOrderResponse> adminDetail(@PathVariable Long id) {
|
||||
return ApiResponse.ok(expressOrderService.detail(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(expressOrderService.detailAdmin(user.getCommunityId(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/express-orders/{id}/confirm")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<ExpressOrderResponse> confirm(@PathVariable Long id) {
|
||||
return ApiResponse.ok(expressOrderService.confirm(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(expressOrderService.confirmAdmin(user.getCommunityId(), id, user.getUserId()));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/express-orders/{id}/adjust-fee")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<ExpressOrderResponse> adjustFee(@PathVariable Long id,
|
||||
@Valid @RequestBody ExpressFeeAdjustRequest request) {
|
||||
return ApiResponse.ok(expressOrderService.adjustFee(id, request.getFeeCent(), request.getReason()));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(expressOrderService.adjustFeeAdmin(user.getCommunityId(), id, request.getFeeCent(), request.getReason()));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/express-orders/{id}/cancel")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<ExpressOrderResponse> adminCancel(@PathVariable Long id, @RequestBody(required = false) ExpressCancelRequest request) {
|
||||
return ApiResponse.ok(expressOrderService.cancel(id, request == null ? null : request.getReason()));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(expressOrderService.cancelAdmin(user.getCommunityId(), id, request == null ? null : request.getReason()));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/express-orders/{id}/offline-pay-status")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<ExpressOrderResponse> updateOfflinePayStatus(@PathVariable Long id,
|
||||
@Valid @RequestBody ExpressOfflinePayRequest request) {
|
||||
return ApiResponse.ok(expressOrderService.updateOfflinePayStatus(id, request.getStatus()));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(expressOrderService.updateOfflinePayStatusAdmin(user.getCommunityId(), id, request.getStatus()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.linhelp.express;
|
||||
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.api.PageUtils;
|
||||
import com.linhelp.common.enums.ExpressOrderStatus;
|
||||
import com.linhelp.common.enums.OfflinePayStatus;
|
||||
import com.linhelp.common.exception.BizException;
|
||||
import com.linhelp.orderlog.OrderLogService;
|
||||
import com.linhelp.user.AddressResponse;
|
||||
import com.linhelp.user.UserAddressService;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -16,13 +21,30 @@ import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Service
|
||||
public class ExpressOrderService {
|
||||
private static final String BIZ_TYPE = "EXPRESS_ORDER";
|
||||
private final UserAddressService addressService;
|
||||
private final OrderLogService orderLogService;
|
||||
private final ExpressOrderStore store;
|
||||
private final AtomicLong orderIds = new AtomicLong(1L);
|
||||
private final AtomicLong logIds = new AtomicLong(1L);
|
||||
private final Map<Long, ExpressOrderResponse> orders = new LinkedHashMap<Long, ExpressOrderResponse>();
|
||||
|
||||
@Autowired
|
||||
public ExpressOrderService(UserAddressService addressService,
|
||||
OrderLogService orderLogService,
|
||||
ObjectProvider<ExpressOrderStore> storeProvider) {
|
||||
this(addressService, orderLogService, storeProvider.getIfAvailable());
|
||||
}
|
||||
|
||||
public ExpressOrderService(UserAddressService addressService) {
|
||||
this(addressService, new OrderLogService(), (ExpressOrderStore) null);
|
||||
}
|
||||
|
||||
public ExpressOrderService(UserAddressService addressService, OrderLogService orderLogService, ExpressOrderStore store) {
|
||||
this.addressService = addressService;
|
||||
this.orderLogService = orderLogService;
|
||||
this.store = store;
|
||||
loadStoredOrders();
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse create(Long userId, ExpressOrderRequest request) {
|
||||
@@ -49,8 +71,10 @@ public class ExpressOrderService {
|
||||
order.setRemark(request.getRemark());
|
||||
order.setCreatedAt(LocalDateTime.now());
|
||||
order.setUpdatedAt(order.getCreatedAt());
|
||||
addLog(order, "CREATE", "用户提交快递代取");
|
||||
addDetailLog(order, "CREATE", "resident created express order");
|
||||
orders.put(order.getId(), order);
|
||||
writeLog(order, null, order.getStatus().name(), "CREATE", userId, "resident created express order");
|
||||
persist(order);
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
@@ -58,6 +82,14 @@ public class ExpressOrderService {
|
||||
return copyOrder(requireOrder(id));
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse detailMine(Long userId, Long id) {
|
||||
return copyOrder(requireMine(userId, id));
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse detailAdmin(Long communityId, Long id) {
|
||||
return copyOrder(requireAdmin(communityId, id));
|
||||
}
|
||||
|
||||
public synchronized List<ExpressOrderResponse> listMine(Long userId) {
|
||||
List<ExpressOrderResponse> result = new ArrayList<ExpressOrderResponse>();
|
||||
for (ExpressOrderResponse order : orders.values()) {
|
||||
@@ -82,9 +114,26 @@ public class ExpressOrderService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public synchronized PageResponse<ExpressOrderResponse> listAdminPage(Long communityId,
|
||||
ExpressOrderStatus status,
|
||||
Integer pageNo,
|
||||
Integer pageSize) {
|
||||
return PageUtils.page(listAdmin(communityId, status), pageNo, pageSize);
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse confirm(Long id) {
|
||||
ExpressOrderResponse order = requireOrder(id);
|
||||
move(order, ExpressOrderStatus.PENDING_PICKUP, "CONFIRM", "后台确认快递代取");
|
||||
move(order, ExpressOrderStatus.PENDING_PICKUP, "CONFIRM", null, "admin confirmed express order");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse confirmAdmin(Long communityId, Long id) {
|
||||
return confirmAdmin(communityId, id, null);
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse confirmAdmin(Long communityId, Long id, Long operatorId) {
|
||||
ExpressOrderResponse order = requireAdmin(communityId, id);
|
||||
move(order, ExpressOrderStatus.PENDING_PICKUP, "CONFIRM", operatorId, "admin confirmed express order");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
@@ -92,39 +141,73 @@ public class ExpressOrderService {
|
||||
ExpressOrderResponse order = requireOrder(id);
|
||||
order.setFeeCent(feeCent);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
addLog(order, "ADJUST_FEE", "调整服务费为" + feeCent + "分:" + reason);
|
||||
String content = "fee adjusted to " + feeCent + ": " + nullToEmpty(reason);
|
||||
addDetailLog(order, "ADJUST_FEE", content);
|
||||
writeLog(order, null, null, "ADJUST_FEE", null, content);
|
||||
persist(order);
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse adjustFeeAdmin(Long communityId, Long id, int feeCent, String reason) {
|
||||
ExpressOrderResponse order = requireAdmin(communityId, id);
|
||||
order.setFeeCent(feeCent);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
String content = "fee adjusted to " + feeCent + ": " + nullToEmpty(reason);
|
||||
addDetailLog(order, "ADJUST_FEE", content);
|
||||
writeLog(order, null, null, "ADJUST_FEE", null, content);
|
||||
persist(order);
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse cancel(Long id, String reason) {
|
||||
ExpressOrderResponse order = requireOrder(id);
|
||||
order.setCancelReason(reason);
|
||||
move(order, ExpressOrderStatus.CANCELED, "CANCEL", "取消订单:" + nullToEmpty(reason));
|
||||
move(order, ExpressOrderStatus.CANCELED, "CANCEL", null, "express order canceled: " + nullToEmpty(reason));
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse cancelMine(Long userId, Long id, String reason) {
|
||||
ExpressOrderResponse order = requireMine(userId, id);
|
||||
order.setCancelReason(reason);
|
||||
move(order, ExpressOrderStatus.CANCELED, "CANCEL", userId, "resident canceled express order: " + nullToEmpty(reason));
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse cancelAdmin(Long communityId, Long id, String reason) {
|
||||
ExpressOrderResponse order = requireAdmin(communityId, id);
|
||||
order.setCancelReason(reason);
|
||||
move(order, ExpressOrderStatus.CANCELED, "CANCEL", null, "admin canceled express order: " + nullToEmpty(reason));
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse complete(Long id) {
|
||||
ExpressOrderResponse order = requireOrder(id);
|
||||
move(order, ExpressOrderStatus.COMPLETED, "COMPLETE", "订单完成");
|
||||
move(order, ExpressOrderStatus.COMPLETED, "COMPLETE", null, "express order completed");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse completeMine(Long userId, Long id) {
|
||||
ExpressOrderResponse order = requireMine(userId, id);
|
||||
move(order, ExpressOrderStatus.COMPLETED, "COMPLETE", userId, "resident confirmed express order completed");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse markPickingUp(Long id) {
|
||||
ExpressOrderResponse order = requireOrder(id);
|
||||
move(order, ExpressOrderStatus.PICKING_UP, "PICKING_UP", "配送员开始取件");
|
||||
move(order, ExpressOrderStatus.PICKING_UP, "PICKING_UP", null, "rider started pickup");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse markDelivering(Long id) {
|
||||
ExpressOrderResponse order = requireOrder(id);
|
||||
move(order, ExpressOrderStatus.DELIVERING, "DELIVERING", "配送员开始配送");
|
||||
move(order, ExpressOrderStatus.DELIVERING, "DELIVERING", null, "rider started delivery");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized ExpressOrderResponse markDelivered(Long id, String photoUrl) {
|
||||
ExpressOrderResponse order = requireOrder(id);
|
||||
order.setDeliveredPhotoUrl(photoUrl);
|
||||
move(order, ExpressOrderStatus.DELIVERED, "DELIVERED", "配送员送达");
|
||||
move(order, ExpressOrderStatus.DELIVERED, "DELIVERED", null, "rider delivered express order");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
@@ -132,28 +215,75 @@ public class ExpressOrderService {
|
||||
ExpressOrderResponse order = requireOrder(id);
|
||||
order.setOfflinePayStatus(status);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
addLog(order, "OFFLINE_PAY", "线下收款状态:" + status.name());
|
||||
addDetailLog(order, "OFFLINE_PAY", "offline payment status: " + status.name());
|
||||
writeLog(order, null, null, "OFFLINE_PAY", null, "offline payment status: " + status.name());
|
||||
persist(order);
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
private void move(ExpressOrderResponse order, ExpressOrderStatus target, String action, String content) {
|
||||
if (!order.getStatus().canMoveTo(target)) {
|
||||
throw new BizException("订单状态不允许操作");
|
||||
public synchronized ExpressOrderResponse updateOfflinePayStatusAdmin(Long communityId, Long id, OfflinePayStatus status) {
|
||||
ExpressOrderResponse order = requireAdmin(communityId, id);
|
||||
order.setOfflinePayStatus(status);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
addDetailLog(order, "OFFLINE_PAY", "offline payment status: " + status.name());
|
||||
writeLog(order, null, null, "OFFLINE_PAY", null, "offline payment status: " + status.name());
|
||||
persist(order);
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
private void loadStoredOrders() {
|
||||
if (store == null) {
|
||||
return;
|
||||
}
|
||||
long maxId = 0L;
|
||||
for (ExpressOrderResponse order : store.loadAll()) {
|
||||
orders.put(order.getId(), copyOrder(order));
|
||||
maxId = Math.max(maxId, order.getId());
|
||||
}
|
||||
orderIds.set(maxId + 1L);
|
||||
}
|
||||
|
||||
private void move(ExpressOrderResponse order,
|
||||
ExpressOrderStatus target,
|
||||
String action,
|
||||
Long operatorId,
|
||||
String content) {
|
||||
if (!order.getStatus().canMoveTo(target)) {
|
||||
throw new BizException("order status does not allow this action");
|
||||
}
|
||||
ExpressOrderStatus from = order.getStatus();
|
||||
order.setStatus(target);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
addLog(order, action, content);
|
||||
addDetailLog(order, action, content);
|
||||
writeLog(order, from.name(), target.name(), action, operatorId, content);
|
||||
persist(order);
|
||||
}
|
||||
|
||||
private ExpressOrderResponse requireOrder(Long id) {
|
||||
ExpressOrderResponse order = orders.get(id);
|
||||
if (order == null) {
|
||||
throw new BizException(404, "快递代取订单不存在");
|
||||
throw new BizException(404, "express order not found");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
private void addLog(ExpressOrderResponse order, String action, String content) {
|
||||
private ExpressOrderResponse requireMine(Long userId, Long id) {
|
||||
ExpressOrderResponse order = requireOrder(id);
|
||||
if (!userId.equals(order.getUserId())) {
|
||||
throw new BizException(404, "express order not found");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
private ExpressOrderResponse requireAdmin(Long communityId, Long id) {
|
||||
ExpressOrderResponse order = requireOrder(id);
|
||||
if (!communityId.equals(order.getCommunityId())) {
|
||||
throw new BizException(404, "express order not found");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
private void addDetailLog(ExpressOrderResponse order, String action, String content) {
|
||||
ExpressOrderLogResponse log = new ExpressOrderLogResponse();
|
||||
log.setId(logIds.getAndIncrement());
|
||||
log.setOrderId(order.getId());
|
||||
@@ -163,6 +293,31 @@ public class ExpressOrderService {
|
||||
order.getLogs().add(log);
|
||||
}
|
||||
|
||||
private void writeLog(ExpressOrderResponse order,
|
||||
String fromStatus,
|
||||
String toStatus,
|
||||
String action,
|
||||
Long operatorId,
|
||||
String note) {
|
||||
orderLogService.record(
|
||||
order.getTenantId(),
|
||||
order.getCommunityId(),
|
||||
BIZ_TYPE,
|
||||
order.getId(),
|
||||
fromStatus,
|
||||
toStatus,
|
||||
action,
|
||||
operatorId,
|
||||
note
|
||||
);
|
||||
}
|
||||
|
||||
private void persist(ExpressOrderResponse order) {
|
||||
if (store != null) {
|
||||
store.save(copyOrder(order));
|
||||
}
|
||||
}
|
||||
|
||||
private String buildDeliveryAddress(AddressResponse address) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
append(builder, address.getBuilding());
|
||||
@@ -215,6 +370,9 @@ public class ExpressOrderService {
|
||||
|
||||
private List<ExpressOrderLogResponse> copyLogs(List<ExpressOrderLogResponse> sourceLogs) {
|
||||
List<ExpressOrderLogResponse> logs = new ArrayList<ExpressOrderLogResponse>();
|
||||
if (sourceLogs == null) {
|
||||
return logs;
|
||||
}
|
||||
for (ExpressOrderLogResponse source : sourceLogs) {
|
||||
ExpressOrderLogResponse target = new ExpressOrderLogResponse();
|
||||
target.setId(source.getId());
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.linhelp.express;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ExpressOrderStore {
|
||||
List<ExpressOrderResponse> loadAll();
|
||||
|
||||
void save(ExpressOrderResponse order);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.linhelp.express;
|
||||
|
||||
import com.linhelp.common.enums.ExpressOrderStatus;
|
||||
import com.linhelp.common.enums.OfflinePayStatus;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
@ConditionalOnBean(JdbcTemplate.class)
|
||||
public class JdbcExpressOrderStore implements ExpressOrderStore {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public JdbcExpressOrderStore(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ExpressOrderResponse> loadAll() {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT id, order_no, tenant_id, community_id, user_id, address_id, contact_name, contact_phone, delivery_address, " +
|
||||
"express_company, pickup_code, pickup_address, receiver_phone, phone, package_count, service_fee_cent, delivered_photo_url, " +
|
||||
"status, offline_pay_status, remark, cancel_reason, created_at, updated_at " +
|
||||
"FROM express_order WHERE deleted = 0 ORDER BY id ASC",
|
||||
this::mapOrder
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(ExpressOrderResponse order) {
|
||||
int updated = jdbcTemplate.update(
|
||||
"UPDATE express_order SET order_no = ?, tenant_id = ?, community_id = ?, user_id = ?, address_id = ?, contact_name = ?, contact_phone = ?, " +
|
||||
"delivery_address = ?, express_company = ?, pickup_code = ?, pickup_address = ?, receiver_phone = ?, phone = ?, package_count = ?, " +
|
||||
"service_fee_cent = ?, delivered_photo_url = ?, status = ?, offline_pay_status = ?, remark = ?, cancel_reason = ?, updated_at = ? WHERE id = ?",
|
||||
order.getOrderNo(),
|
||||
order.getTenantId(),
|
||||
order.getCommunityId(),
|
||||
order.getUserId(),
|
||||
order.getAddressId(),
|
||||
order.getContactName(),
|
||||
order.getContactPhone(),
|
||||
order.getDeliveryAddress(),
|
||||
order.getExpressCompany(),
|
||||
order.getPickupCode(),
|
||||
order.getPickupAddress(),
|
||||
order.getReceiverPhone(),
|
||||
phoneForLegacyColumn(order),
|
||||
order.getPackageCount(),
|
||||
order.getFeeCent(),
|
||||
order.getDeliveredPhotoUrl(),
|
||||
order.getStatus().name(),
|
||||
order.getOfflinePayStatus().name(),
|
||||
order.getRemark(),
|
||||
order.getCancelReason(),
|
||||
toTimestamp(order.getUpdatedAt()),
|
||||
order.getId()
|
||||
);
|
||||
if (updated == 0) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO express_order (id, order_no, tenant_id, community_id, user_id, address_id, contact_name, contact_phone, delivery_address, " +
|
||||
"express_company, pickup_code, pickup_address, receiver_phone, phone, package_count, service_fee_cent, delivered_photo_url, " +
|
||||
"status, offline_pay_status, remark, cancel_reason, created_at, updated_at) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
order.getId(),
|
||||
order.getOrderNo(),
|
||||
order.getTenantId(),
|
||||
order.getCommunityId(),
|
||||
order.getUserId(),
|
||||
order.getAddressId(),
|
||||
order.getContactName(),
|
||||
order.getContactPhone(),
|
||||
order.getDeliveryAddress(),
|
||||
order.getExpressCompany(),
|
||||
order.getPickupCode(),
|
||||
order.getPickupAddress(),
|
||||
order.getReceiverPhone(),
|
||||
phoneForLegacyColumn(order),
|
||||
order.getPackageCount(),
|
||||
order.getFeeCent(),
|
||||
order.getDeliveredPhotoUrl(),
|
||||
order.getStatus().name(),
|
||||
order.getOfflinePayStatus().name(),
|
||||
order.getRemark(),
|
||||
order.getCancelReason(),
|
||||
toTimestamp(order.getCreatedAt()),
|
||||
toTimestamp(order.getUpdatedAt())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private ExpressOrderResponse mapOrder(ResultSet rs, int rowNum) throws SQLException {
|
||||
ExpressOrderResponse order = new ExpressOrderResponse();
|
||||
order.setId(rs.getLong("id"));
|
||||
order.setOrderNo(rs.getString("order_no"));
|
||||
order.setTenantId(rs.getLong("tenant_id"));
|
||||
order.setCommunityId(rs.getLong("community_id"));
|
||||
order.setUserId(rs.getLong("user_id"));
|
||||
order.setAddressId(rs.getLong("address_id"));
|
||||
order.setContactName(rs.getString("contact_name"));
|
||||
order.setContactPhone(rs.getString("contact_phone"));
|
||||
order.setDeliveryAddress(rs.getString("delivery_address"));
|
||||
order.setExpressCompany(rs.getString("express_company"));
|
||||
order.setPickupCode(rs.getString("pickup_code"));
|
||||
order.setPickupAddress(rs.getString("pickup_address"));
|
||||
String receiverPhone = rs.getString("receiver_phone");
|
||||
order.setReceiverPhone(receiverPhone == null ? rs.getString("phone") : receiverPhone);
|
||||
order.setPackageCount(rs.getInt("package_count"));
|
||||
order.setFeeCent(rs.getInt("service_fee_cent"));
|
||||
order.setDeliveredPhotoUrl(rs.getString("delivered_photo_url"));
|
||||
order.setStatus(ExpressOrderStatus.valueOf(rs.getString("status")));
|
||||
order.setOfflinePayStatus(OfflinePayStatus.valueOf(rs.getString("offline_pay_status")));
|
||||
order.setRemark(rs.getString("remark"));
|
||||
order.setCancelReason(rs.getString("cancel_reason"));
|
||||
order.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
|
||||
order.setUpdatedAt(rs.getTimestamp("updated_at").toLocalDateTime());
|
||||
return order;
|
||||
}
|
||||
|
||||
private String phoneForLegacyColumn(ExpressOrderResponse order) {
|
||||
return order.getReceiverPhone() == null ? order.getContactPhone() : order.getReceiverPhone();
|
||||
}
|
||||
|
||||
private Timestamp toTimestamp(LocalDateTime value) {
|
||||
return value == null ? null : Timestamp.valueOf(value);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.linhelp.groupbuy;
|
||||
|
||||
import com.linhelp.common.api.ApiResponse;
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.enums.GroupBuyOrderStatus;
|
||||
import com.linhelp.common.security.AuthService;
|
||||
import com.linhelp.common.security.CurrentUser;
|
||||
import com.linhelp.common.security.RequireRole;
|
||||
@@ -9,6 +11,7 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.validation.Valid;
|
||||
@@ -50,43 +53,68 @@ public class GroupBuyController {
|
||||
CurrentUser user = authService.currentUser();
|
||||
request.setTenantId(user.getTenantId());
|
||||
request.setCommunityId(user.getCommunityId());
|
||||
return ApiResponse.ok(groupBuyService.update(id, request));
|
||||
return ApiResponse.ok(groupBuyService.updateAdmin(user.getCommunityId(), id, request));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/group-buys/{id}/start")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<GroupBuyResponse> start(@PathVariable Long id) {
|
||||
return ApiResponse.ok(groupBuyService.start(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(groupBuyService.startAdmin(user.getCommunityId(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/group-buys/{id}/close")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<GroupBuyResponse> close(@PathVariable Long id) {
|
||||
return ApiResponse.ok(groupBuyService.close(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(groupBuyService.closeAdmin(user.getCommunityId(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/group-buys/{id}/complete")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<GroupBuyResponse> completeGroupBuy(@PathVariable Long id) {
|
||||
return ApiResponse.ok(groupBuyService.complete(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(groupBuyService.completeAdmin(user.getCommunityId(), id));
|
||||
}
|
||||
|
||||
@GetMapping("/api/admin/group-buys/{id}/orders")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<List<GroupBuyOrderResponse>> listOrders(@PathVariable Long id) {
|
||||
return ApiResponse.ok(orderService.listByGroupBuy(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(orderService.listByGroupBuyAdmin(user.getCommunityId(), id));
|
||||
}
|
||||
|
||||
@GetMapping("/api/admin/group-buys/{id}/orders/page")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<PageResponse<GroupBuyOrderResponse>> pageOrders(@PathVariable Long id,
|
||||
@RequestParam(required = false) GroupBuyOrderStatus status,
|
||||
@RequestParam(required = false) Integer pageNo,
|
||||
@RequestParam(required = false) Integer pageSize) {
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(orderService.listByGroupBuyAdminPage(user.getCommunityId(), id, status, pageNo, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/api/admin/group-buy-orders/page")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<PageResponse<GroupBuyOrderResponse>> pageAllOrders(@RequestParam(required = false) GroupBuyOrderStatus status,
|
||||
@RequestParam(required = false) Integer pageNo,
|
||||
@RequestParam(required = false) Integer pageSize) {
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(orderService.listAdminPage(user.getCommunityId(), status, pageNo, pageSize));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/group-buy-orders/{id}/confirm")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<GroupBuyOrderResponse> confirmOrder(@PathVariable Long id) {
|
||||
return ApiResponse.ok(orderService.confirm(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(orderService.confirmAdmin(user.getCommunityId(), id, user.getUserId()));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/group-buy-orders/{id}/ready")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<GroupBuyOrderResponse> markOrderReady(@PathVariable Long id) {
|
||||
return ApiResponse.ok(orderService.markReady(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(orderService.markReadyAdmin(user.getCommunityId(), id));
|
||||
}
|
||||
|
||||
@GetMapping("/api/mini/group-buys")
|
||||
@@ -97,7 +125,8 @@ public class GroupBuyController {
|
||||
|
||||
@GetMapping("/api/mini/group-buys/{id}")
|
||||
public ApiResponse<GroupBuyResponse> detail(@PathVariable Long id) {
|
||||
return ApiResponse.ok(groupBuyService.detail(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(groupBuyService.detailMini(user.getCommunityId(), id));
|
||||
}
|
||||
|
||||
@PostMapping("/api/mini/group-buy-orders")
|
||||
@@ -116,16 +145,19 @@ public class GroupBuyController {
|
||||
|
||||
@GetMapping("/api/mini/group-buy-orders/{id}")
|
||||
public ApiResponse<GroupBuyOrderResponse> orderDetail(@PathVariable Long id) {
|
||||
return ApiResponse.ok(orderService.detail(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(orderService.detailMine(user.getUserId(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/api/mini/group-buy-orders/{id}/cancel")
|
||||
public ApiResponse<GroupBuyOrderResponse> cancel(@PathVariable Long id) {
|
||||
return ApiResponse.ok(orderService.cancel(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(orderService.cancelMine(user.getUserId(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/api/mini/group-buy-orders/{id}/complete")
|
||||
public ApiResponse<GroupBuyOrderResponse> completeOrder(@PathVariable Long id) {
|
||||
return ApiResponse.ok(orderService.complete(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(orderService.completeMine(user.getUserId(), id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.linhelp.groupbuy;
|
||||
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.api.PageUtils;
|
||||
import com.linhelp.common.enums.DeliveryMethod;
|
||||
import com.linhelp.common.enums.GroupBuyOrderStatus;
|
||||
import com.linhelp.common.enums.OfflinePayStatus;
|
||||
import com.linhelp.common.exception.BizException;
|
||||
import com.linhelp.orderlog.OrderLogService;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -15,17 +20,34 @@ import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Service
|
||||
public class GroupBuyOrderService {
|
||||
private static final String BIZ_TYPE = "GROUP_BUY_ORDER";
|
||||
private final GroupBuyService groupBuyService;
|
||||
private final OrderLogService orderLogService;
|
||||
private final GroupBuyOrderStore store;
|
||||
private final AtomicLong ids = new AtomicLong(1L);
|
||||
private final Map<Long, GroupBuyOrderResponse> orders = new LinkedHashMap<Long, GroupBuyOrderResponse>();
|
||||
|
||||
@Autowired
|
||||
public GroupBuyOrderService(GroupBuyService groupBuyService,
|
||||
OrderLogService orderLogService,
|
||||
ObjectProvider<GroupBuyOrderStore> storeProvider) {
|
||||
this(groupBuyService, orderLogService, storeProvider.getIfAvailable());
|
||||
}
|
||||
|
||||
public GroupBuyOrderService(GroupBuyService groupBuyService) {
|
||||
this(groupBuyService, new OrderLogService(), (GroupBuyOrderStore) null);
|
||||
}
|
||||
|
||||
public GroupBuyOrderService(GroupBuyService groupBuyService, OrderLogService orderLogService, GroupBuyOrderStore store) {
|
||||
this.groupBuyService = groupBuyService;
|
||||
this.orderLogService = orderLogService;
|
||||
this.store = store;
|
||||
loadStoredOrders();
|
||||
}
|
||||
|
||||
public synchronized GroupBuyOrderResponse create(Long userId, GroupBuyOrderRequest request) {
|
||||
if (request.getDeliveryMethod() != DeliveryMethod.SELF_PICKUP && request.getAddressId() == null) {
|
||||
throw new BizException("配送地址不能为空");
|
||||
throw new BizException("delivery address is required");
|
||||
}
|
||||
GroupBuyResponse groupBuy = groupBuyService.reserveForOrder(request.getGroupBuyId(), request.getQuantity());
|
||||
Long orderId = ids.getAndIncrement();
|
||||
@@ -49,6 +71,8 @@ public class GroupBuyOrderService {
|
||||
order.setCreatedAt(LocalDateTime.now());
|
||||
order.setUpdatedAt(order.getCreatedAt());
|
||||
orders.put(order.getId(), order);
|
||||
writeLog(order, null, order.getStatus().name(), "CREATE", userId, "resident created group buy order");
|
||||
persist(order);
|
||||
return copy(order);
|
||||
}
|
||||
|
||||
@@ -56,6 +80,14 @@ public class GroupBuyOrderService {
|
||||
return copy(require(id));
|
||||
}
|
||||
|
||||
public synchronized GroupBuyOrderResponse detailMine(Long userId, Long id) {
|
||||
return copy(requireMine(userId, id));
|
||||
}
|
||||
|
||||
public synchronized GroupBuyOrderResponse detailAdmin(Long communityId, Long id) {
|
||||
return copy(requireAdmin(communityId, id));
|
||||
}
|
||||
|
||||
public synchronized List<GroupBuyOrderResponse> listMine(Long userId) {
|
||||
List<GroupBuyOrderResponse> result = new ArrayList<GroupBuyOrderResponse>();
|
||||
for (GroupBuyOrderResponse order : orders.values()) {
|
||||
@@ -76,59 +108,221 @@ public class GroupBuyOrderService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public synchronized List<GroupBuyOrderResponse> listByGroupBuyAdmin(Long communityId, Long groupBuyId) {
|
||||
List<GroupBuyOrderResponse> result = new ArrayList<GroupBuyOrderResponse>();
|
||||
for (GroupBuyOrderResponse order : orders.values()) {
|
||||
if (communityId.equals(order.getCommunityId()) && groupBuyId.equals(order.getGroupBuyId())) {
|
||||
result.add(copy(order));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public synchronized PageResponse<GroupBuyOrderResponse> listByGroupBuyAdminPage(Long communityId,
|
||||
Long groupBuyId,
|
||||
GroupBuyOrderStatus status,
|
||||
Integer pageNo,
|
||||
Integer pageSize) {
|
||||
List<GroupBuyOrderResponse> result = new ArrayList<GroupBuyOrderResponse>();
|
||||
for (GroupBuyOrderResponse order : orders.values()) {
|
||||
if (!communityId.equals(order.getCommunityId()) || !groupBuyId.equals(order.getGroupBuyId())) {
|
||||
continue;
|
||||
}
|
||||
if (status != null && status != order.getStatus()) {
|
||||
continue;
|
||||
}
|
||||
result.add(copy(order));
|
||||
}
|
||||
return PageUtils.page(result, pageNo, pageSize);
|
||||
}
|
||||
|
||||
public synchronized PageResponse<GroupBuyOrderResponse> listAdminPage(Long communityId,
|
||||
GroupBuyOrderStatus status,
|
||||
Integer pageNo,
|
||||
Integer pageSize) {
|
||||
List<GroupBuyOrderResponse> result = new ArrayList<GroupBuyOrderResponse>();
|
||||
for (GroupBuyOrderResponse order : orders.values()) {
|
||||
if (!communityId.equals(order.getCommunityId())) {
|
||||
continue;
|
||||
}
|
||||
if (status != null && status != order.getStatus()) {
|
||||
continue;
|
||||
}
|
||||
result.add(copy(order));
|
||||
}
|
||||
return PageUtils.page(result, pageNo, pageSize);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyOrderResponse confirm(Long id) {
|
||||
GroupBuyOrderResponse order = require(id);
|
||||
move(order, GroupBuyOrderStatus.PENDING_CONFIRM, GroupBuyOrderStatus.CONFIRMED);
|
||||
move(order, GroupBuyOrderStatus.PENDING_CONFIRM, GroupBuyOrderStatus.CONFIRMED, "CONFIRM", null, "admin confirmed group buy order");
|
||||
return copy(order);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyOrderResponse confirmAdmin(Long communityId, Long id) {
|
||||
return confirmAdmin(communityId, id, null);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyOrderResponse confirmAdmin(Long communityId, Long id, Long operatorId) {
|
||||
GroupBuyOrderResponse order = requireAdmin(communityId, id);
|
||||
move(order, GroupBuyOrderStatus.PENDING_CONFIRM, GroupBuyOrderStatus.CONFIRMED, "CONFIRM", operatorId, "admin confirmed group buy order");
|
||||
return copy(order);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyOrderResponse markReady(Long id) {
|
||||
GroupBuyOrderResponse order = require(id);
|
||||
GroupBuyOrderStatus target = DeliveryMethod.SELF_PICKUP.equals(order.getDeliveryMethod())
|
||||
? GroupBuyOrderStatus.PENDING_PICKUP
|
||||
: GroupBuyOrderStatus.PENDING_DELIVERY;
|
||||
move(order, GroupBuyOrderStatus.CONFIRMED, target);
|
||||
move(order, GroupBuyOrderStatus.CONFIRMED, readyTarget(order), "READY", null, "group buy order ready");
|
||||
return copy(order);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyOrderResponse markReadyAdmin(Long communityId, Long id) {
|
||||
GroupBuyOrderResponse order = requireAdmin(communityId, id);
|
||||
move(order, GroupBuyOrderStatus.CONFIRMED, readyTarget(order), "READY", null, "group buy order ready");
|
||||
return copy(order);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyOrderResponse markDelivering(Long id) {
|
||||
GroupBuyOrderResponse order = require(id);
|
||||
move(order, GroupBuyOrderStatus.PENDING_DELIVERY, GroupBuyOrderStatus.DELIVERING);
|
||||
move(order, GroupBuyOrderStatus.PENDING_DELIVERY, GroupBuyOrderStatus.DELIVERING, "DELIVERING", null, "rider started delivery");
|
||||
return copy(order);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyOrderResponse complete(Long id) {
|
||||
GroupBuyOrderResponse order = require(id);
|
||||
if (order.getStatus() != GroupBuyOrderStatus.PENDING_PICKUP && order.getStatus() != GroupBuyOrderStatus.DELIVERING) {
|
||||
throw new BizException("订单状态不允许操作");
|
||||
completeOrder(order, null, "group buy order completed");
|
||||
return copy(order);
|
||||
}
|
||||
order.setStatus(GroupBuyOrderStatus.COMPLETED);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
public synchronized GroupBuyOrderResponse completeMine(Long userId, Long id) {
|
||||
GroupBuyOrderResponse order = requireMine(userId, id);
|
||||
completeOrder(order, userId, "resident confirmed group buy order completed");
|
||||
return copy(order);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyOrderResponse completeAdmin(Long communityId, Long id) {
|
||||
GroupBuyOrderResponse order = requireAdmin(communityId, id);
|
||||
completeOrder(order, null, "admin completed group buy order");
|
||||
return copy(order);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyOrderResponse cancel(Long id) {
|
||||
GroupBuyOrderResponse order = require(id);
|
||||
if (order.getStatus() == GroupBuyOrderStatus.COMPLETED) {
|
||||
throw new BizException("订单状态不允许操作");
|
||||
}
|
||||
order.setStatus(GroupBuyOrderStatus.CANCELED);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
cancelOrder(order, null, "group buy order canceled");
|
||||
return copy(order);
|
||||
}
|
||||
|
||||
private void move(GroupBuyOrderResponse order, GroupBuyOrderStatus expected, GroupBuyOrderStatus target) {
|
||||
public synchronized GroupBuyOrderResponse cancelMine(Long userId, Long id) {
|
||||
GroupBuyOrderResponse order = requireMine(userId, id);
|
||||
cancelOrder(order, userId, "resident canceled group buy order");
|
||||
return copy(order);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyOrderResponse cancelAdmin(Long communityId, Long id) {
|
||||
GroupBuyOrderResponse order = requireAdmin(communityId, id);
|
||||
cancelOrder(order, null, "admin canceled group buy order");
|
||||
return copy(order);
|
||||
}
|
||||
|
||||
private void loadStoredOrders() {
|
||||
if (store == null) {
|
||||
return;
|
||||
}
|
||||
long maxId = 0L;
|
||||
for (GroupBuyOrderResponse order : store.loadAll()) {
|
||||
orders.put(order.getId(), copy(order));
|
||||
maxId = Math.max(maxId, order.getId());
|
||||
}
|
||||
ids.set(maxId + 1L);
|
||||
}
|
||||
|
||||
private GroupBuyOrderStatus readyTarget(GroupBuyOrderResponse order) {
|
||||
return DeliveryMethod.SELF_PICKUP.equals(order.getDeliveryMethod())
|
||||
? GroupBuyOrderStatus.PENDING_PICKUP
|
||||
: GroupBuyOrderStatus.PENDING_DELIVERY;
|
||||
}
|
||||
|
||||
private void completeOrder(GroupBuyOrderResponse order, Long operatorId, String note) {
|
||||
if (order.getStatus() != GroupBuyOrderStatus.PENDING_PICKUP && order.getStatus() != GroupBuyOrderStatus.DELIVERING) {
|
||||
throw new BizException("order status does not allow this action");
|
||||
}
|
||||
GroupBuyOrderStatus from = order.getStatus();
|
||||
order.setStatus(GroupBuyOrderStatus.COMPLETED);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
writeLog(order, from.name(), order.getStatus().name(), "COMPLETE", operatorId, note);
|
||||
persist(order);
|
||||
}
|
||||
|
||||
private void cancelOrder(GroupBuyOrderResponse order, Long operatorId, String note) {
|
||||
if (order.getStatus() == GroupBuyOrderStatus.COMPLETED) {
|
||||
throw new BizException("order status does not allow this action");
|
||||
}
|
||||
GroupBuyOrderStatus from = order.getStatus();
|
||||
order.setStatus(GroupBuyOrderStatus.CANCELED);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
writeLog(order, from.name(), order.getStatus().name(), "CANCEL", operatorId, note);
|
||||
persist(order);
|
||||
}
|
||||
|
||||
private void move(GroupBuyOrderResponse order,
|
||||
GroupBuyOrderStatus expected,
|
||||
GroupBuyOrderStatus target,
|
||||
String action,
|
||||
Long operatorId,
|
||||
String note) {
|
||||
if (order.getStatus() != expected) {
|
||||
throw new BizException("订单状态不允许操作");
|
||||
throw new BizException("order status does not allow this action");
|
||||
}
|
||||
order.setStatus(target);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
writeLog(order, expected.name(), target.name(), action, operatorId, note);
|
||||
persist(order);
|
||||
}
|
||||
|
||||
private void writeLog(GroupBuyOrderResponse order,
|
||||
String fromStatus,
|
||||
String toStatus,
|
||||
String action,
|
||||
Long operatorId,
|
||||
String note) {
|
||||
orderLogService.record(
|
||||
order.getTenantId(),
|
||||
order.getCommunityId(),
|
||||
BIZ_TYPE,
|
||||
order.getId(),
|
||||
fromStatus,
|
||||
toStatus,
|
||||
action,
|
||||
operatorId,
|
||||
note
|
||||
);
|
||||
}
|
||||
|
||||
private void persist(GroupBuyOrderResponse order) {
|
||||
if (store != null) {
|
||||
store.save(copy(order));
|
||||
}
|
||||
}
|
||||
|
||||
private GroupBuyOrderResponse require(Long id) {
|
||||
GroupBuyOrderResponse order = orders.get(id);
|
||||
if (order == null) {
|
||||
throw new BizException(404, "团购订单不存在");
|
||||
throw new BizException(404, "group buy order not found");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
private GroupBuyOrderResponse requireMine(Long userId, Long id) {
|
||||
GroupBuyOrderResponse order = require(id);
|
||||
if (!userId.equals(order.getUserId())) {
|
||||
throw new BizException(404, "group buy order not found");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
private GroupBuyOrderResponse requireAdmin(Long communityId, Long id) {
|
||||
GroupBuyOrderResponse order = require(id);
|
||||
if (!communityId.equals(order.getCommunityId())) {
|
||||
throw new BizException(404, "group buy order not found");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.linhelp.groupbuy;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface GroupBuyOrderStore {
|
||||
List<GroupBuyOrderResponse> loadAll();
|
||||
|
||||
void save(GroupBuyOrderResponse order);
|
||||
}
|
||||
@@ -43,28 +43,66 @@ public class GroupBuyService {
|
||||
return copy(groupBuy);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyResponse updateAdmin(Long communityId, Long id, GroupBuyRequest request) {
|
||||
GroupBuyResponse groupBuy = requireAdmin(communityId, id);
|
||||
if (request.getTenantId() != null) {
|
||||
groupBuy.setTenantId(request.getTenantId());
|
||||
}
|
||||
if (request.getCommunityId() != null) {
|
||||
groupBuy.setCommunityId(request.getCommunityId());
|
||||
}
|
||||
copyFields(request, groupBuy);
|
||||
return copy(groupBuy);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyResponse start(Long id) {
|
||||
GroupBuyResponse groupBuy = require(id);
|
||||
groupBuy.setStatus(STATUS_ONGOING);
|
||||
return copy(groupBuy);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyResponse startAdmin(Long communityId, Long id) {
|
||||
GroupBuyResponse groupBuy = requireAdmin(communityId, id);
|
||||
groupBuy.setStatus(STATUS_ONGOING);
|
||||
return copy(groupBuy);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyResponse close(Long id) {
|
||||
GroupBuyResponse groupBuy = require(id);
|
||||
groupBuy.setStatus(STATUS_CLOSED);
|
||||
return copy(groupBuy);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyResponse closeAdmin(Long communityId, Long id) {
|
||||
GroupBuyResponse groupBuy = requireAdmin(communityId, id);
|
||||
groupBuy.setStatus(STATUS_CLOSED);
|
||||
return copy(groupBuy);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyResponse complete(Long id) {
|
||||
GroupBuyResponse groupBuy = require(id);
|
||||
groupBuy.setStatus(STATUS_COMPLETED);
|
||||
return copy(groupBuy);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyResponse completeAdmin(Long communityId, Long id) {
|
||||
GroupBuyResponse groupBuy = requireAdmin(communityId, id);
|
||||
groupBuy.setStatus(STATUS_COMPLETED);
|
||||
return copy(groupBuy);
|
||||
}
|
||||
|
||||
public synchronized GroupBuyResponse detail(Long id) {
|
||||
return copy(require(id));
|
||||
}
|
||||
|
||||
public synchronized GroupBuyResponse detailMini(Long communityId, Long id) {
|
||||
GroupBuyResponse groupBuy = requireAdmin(communityId, id);
|
||||
if (!STATUS_ONGOING.equals(groupBuy.getStatus())) {
|
||||
throw new BizException(404, "团购不存在");
|
||||
}
|
||||
return copy(groupBuy);
|
||||
}
|
||||
|
||||
public synchronized List<GroupBuyResponse> listAdmin(Long communityId) {
|
||||
List<GroupBuyResponse> result = new ArrayList<GroupBuyResponse>();
|
||||
for (GroupBuyResponse groupBuy : groupBuys.values()) {
|
||||
@@ -108,6 +146,14 @@ public class GroupBuyService {
|
||||
return groupBuy;
|
||||
}
|
||||
|
||||
private GroupBuyResponse requireAdmin(Long communityId, Long id) {
|
||||
GroupBuyResponse groupBuy = require(id);
|
||||
if (!communityId.equals(groupBuy.getCommunityId())) {
|
||||
throw new BizException(404, "团购不存在");
|
||||
}
|
||||
return groupBuy;
|
||||
}
|
||||
|
||||
private void copyFields(GroupBuyRequest request, GroupBuyResponse groupBuy) {
|
||||
groupBuy.setTitle(request.getTitle());
|
||||
groupBuy.setCoverUrl(request.getCoverUrl());
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.linhelp.groupbuy;
|
||||
|
||||
import com.linhelp.common.enums.DeliveryMethod;
|
||||
import com.linhelp.common.enums.GroupBuyOrderStatus;
|
||||
import com.linhelp.common.enums.OfflinePayStatus;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
@ConditionalOnBean(JdbcTemplate.class)
|
||||
public class JdbcGroupBuyOrderStore implements GroupBuyOrderStore {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public JdbcGroupBuyOrderStore(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GroupBuyOrderResponse> loadAll() {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT id, order_no, tenant_id, community_id, group_buy_id, group_buy_title, cover_url, user_id, address_id, " +
|
||||
"delivery_method, quantity, price_cent, amount_cent, status, offline_pay_status, remark, created_at, updated_at " +
|
||||
"FROM group_buy_order WHERE deleted = 0 ORDER BY id ASC",
|
||||
this::mapOrder
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(GroupBuyOrderResponse order) {
|
||||
int updated = jdbcTemplate.update(
|
||||
"UPDATE group_buy_order SET order_no = ?, tenant_id = ?, community_id = ?, group_buy_id = ?, group_buy_title = ?, cover_url = ?, " +
|
||||
"user_id = ?, address_id = ?, delivery_method = ?, quantity = ?, price_cent = ?, amount_cent = ?, status = ?, " +
|
||||
"offline_pay_status = ?, remark = ?, updated_at = ? WHERE id = ?",
|
||||
order.getOrderNo(),
|
||||
order.getTenantId(),
|
||||
order.getCommunityId(),
|
||||
order.getGroupBuyId(),
|
||||
order.getGroupBuyTitle(),
|
||||
order.getCoverUrl(),
|
||||
order.getUserId(),
|
||||
order.getAddressId(),
|
||||
order.getDeliveryMethod().name(),
|
||||
order.getQuantity(),
|
||||
order.getPriceCent(),
|
||||
order.getAmountCent(),
|
||||
order.getStatus().name(),
|
||||
order.getOfflinePayStatus().name(),
|
||||
order.getRemark(),
|
||||
toTimestamp(order.getUpdatedAt()),
|
||||
order.getId()
|
||||
);
|
||||
if (updated == 0) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO group_buy_order (id, order_no, tenant_id, community_id, group_buy_id, group_buy_title, cover_url, user_id, address_id, " +
|
||||
"delivery_method, quantity, price_cent, amount_cent, status, offline_pay_status, remark, created_at, updated_at) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
order.getId(),
|
||||
order.getOrderNo(),
|
||||
order.getTenantId(),
|
||||
order.getCommunityId(),
|
||||
order.getGroupBuyId(),
|
||||
order.getGroupBuyTitle(),
|
||||
order.getCoverUrl(),
|
||||
order.getUserId(),
|
||||
order.getAddressId(),
|
||||
order.getDeliveryMethod().name(),
|
||||
order.getQuantity(),
|
||||
order.getPriceCent(),
|
||||
order.getAmountCent(),
|
||||
order.getStatus().name(),
|
||||
order.getOfflinePayStatus().name(),
|
||||
order.getRemark(),
|
||||
toTimestamp(order.getCreatedAt()),
|
||||
toTimestamp(order.getUpdatedAt())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private GroupBuyOrderResponse mapOrder(ResultSet rs, int rowNum) throws SQLException {
|
||||
GroupBuyOrderResponse order = new GroupBuyOrderResponse();
|
||||
order.setId(rs.getLong("id"));
|
||||
order.setOrderNo(rs.getString("order_no"));
|
||||
order.setTenantId(rs.getLong("tenant_id"));
|
||||
order.setCommunityId(rs.getLong("community_id"));
|
||||
order.setGroupBuyId(rs.getLong("group_buy_id"));
|
||||
order.setGroupBuyTitle(rs.getString("group_buy_title"));
|
||||
order.setCoverUrl(rs.getString("cover_url"));
|
||||
order.setUserId(rs.getLong("user_id"));
|
||||
long addressId = rs.getLong("address_id");
|
||||
order.setAddressId(rs.wasNull() ? null : addressId);
|
||||
order.setDeliveryMethod(DeliveryMethod.valueOf(rs.getString("delivery_method")));
|
||||
order.setQuantity(rs.getInt("quantity"));
|
||||
order.setPriceCent(rs.getInt("price_cent"));
|
||||
order.setAmountCent(rs.getInt("amount_cent"));
|
||||
order.setStatus(GroupBuyOrderStatus.valueOf(rs.getString("status")));
|
||||
order.setOfflinePayStatus(OfflinePayStatus.valueOf(rs.getString("offline_pay_status")));
|
||||
order.setRemark(rs.getString("remark"));
|
||||
order.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
|
||||
order.setUpdatedAt(rs.getTimestamp("updated_at").toLocalDateTime());
|
||||
return order;
|
||||
}
|
||||
|
||||
private Timestamp toTimestamp(LocalDateTime value) {
|
||||
return value == null ? null : Timestamp.valueOf(value);
|
||||
}
|
||||
}
|
||||
@@ -69,11 +69,13 @@ public class NoticeController {
|
||||
|
||||
@GetMapping("/api/mini/notices")
|
||||
public ApiResponse<List<NoticeResponse>> miniList() {
|
||||
return ApiResponse.ok(noticeService.listPublished(1L));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(noticeService.listPublished(user.getCommunityId()));
|
||||
}
|
||||
|
||||
@GetMapping("/api/mini/notices/{id}")
|
||||
public ApiResponse<NoticeResponse> miniDetail(@PathVariable Long id) {
|
||||
return ApiResponse.ok(noticeService.detail(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(noticeService.detailPublished(user.getCommunityId(), id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,14 @@ public class NoticeService {
|
||||
return require(id);
|
||||
}
|
||||
|
||||
public synchronized NoticeResponse detailPublished(Long communityId, Long id) {
|
||||
NoticeResponse notice = require(id);
|
||||
if (!communityId.equals(notice.getCommunityId()) || !STATUS_PUBLISHED.equals(notice.getStatus())) {
|
||||
throw new BizException(404, "公告不存在");
|
||||
}
|
||||
return notice;
|
||||
}
|
||||
|
||||
public synchronized List<NoticeResponse> listPublished(Long communityId) {
|
||||
List<NoticeResponse> result = new ArrayList<NoticeResponse>();
|
||||
for (NoticeResponse notice : notices.values()) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.linhelp.order;
|
||||
|
||||
import com.linhelp.common.api.ApiResponse;
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.enums.GoodsOrderStatus;
|
||||
import com.linhelp.common.security.AuthService;
|
||||
import com.linhelp.common.security.CurrentUser;
|
||||
@@ -42,12 +43,14 @@ public class GoodsOrderController {
|
||||
|
||||
@GetMapping("/api/mini/goods-orders/{id}")
|
||||
public ApiResponse<GoodsOrderResponse> miniDetail(@PathVariable Long id) {
|
||||
return ApiResponse.ok(goodsOrderService.detail(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(goodsOrderService.detailMine(user.getUserId(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/api/mini/goods-orders/{id}/cancel")
|
||||
public ApiResponse<GoodsOrderResponse> cancel(@PathVariable Long id) {
|
||||
return ApiResponse.ok(goodsOrderService.cancel(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(goodsOrderService.cancelMine(user.getUserId(), id));
|
||||
}
|
||||
|
||||
@GetMapping("/api/admin/goods-orders")
|
||||
@@ -57,39 +60,61 @@ public class GoodsOrderController {
|
||||
return ApiResponse.ok(goodsOrderService.listAdmin(user.getCommunityId(), status));
|
||||
}
|
||||
|
||||
@GetMapping("/api/admin/goods-orders/page")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<PageResponse<GoodsOrderResponse>> pageAdmin(@RequestParam(required = false) GoodsOrderStatus status,
|
||||
@RequestParam(required = false) Integer pageNo,
|
||||
@RequestParam(required = false) Integer pageSize) {
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(goodsOrderService.listAdminPage(user.getCommunityId(), status, pageNo, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/api/admin/goods-orders/{id}")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<GoodsOrderResponse> adminDetail(@PathVariable Long id) {
|
||||
return ApiResponse.ok(goodsOrderService.detail(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(goodsOrderService.detailAdmin(user.getCommunityId(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/goods-orders/{id}/confirm")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<GoodsOrderResponse> confirm(@PathVariable Long id) {
|
||||
return ApiResponse.ok(goodsOrderService.confirm(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(goodsOrderService.confirmAdmin(user.getCommunityId(), id, user.getUserId()));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/goods-orders/{id}/prepared")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<GoodsOrderResponse> markPrepared(@PathVariable Long id) {
|
||||
return ApiResponse.ok(goodsOrderService.markPrepared(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(goodsOrderService.markPreparedAdmin(user.getCommunityId(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/goods-orders/{id}/delivering")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<GoodsOrderResponse> markDelivering(@PathVariable Long id) {
|
||||
return ApiResponse.ok(goodsOrderService.markDelivering(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(goodsOrderService.markDeliveringAdmin(user.getCommunityId(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/goods-orders/{id}/complete")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<GoodsOrderResponse> complete(@PathVariable Long id) {
|
||||
return ApiResponse.ok(goodsOrderService.complete(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(goodsOrderService.completeAdmin(user.getCommunityId(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/goods-orders/{id}/cancel")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<GoodsOrderResponse> adminCancel(@PathVariable Long id) {
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(goodsOrderService.cancelAdmin(user.getCommunityId(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/api/admin/goods-orders/{id}/paid")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<GoodsOrderResponse> markPaid(@PathVariable Long id) {
|
||||
return ApiResponse.ok(goodsOrderService.markPaid(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(goodsOrderService.markPaidAdmin(user.getCommunityId(), id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.linhelp.order;
|
||||
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.api.PageUtils;
|
||||
import com.linhelp.common.enums.DeliveryMethod;
|
||||
import com.linhelp.common.enums.GoodsOrderStatus;
|
||||
import com.linhelp.common.enums.OfflinePayStatus;
|
||||
import com.linhelp.common.exception.BizException;
|
||||
import com.linhelp.orderlog.OrderLogService;
|
||||
import com.linhelp.product.ProductService;
|
||||
import com.linhelp.product.ProductSkuResponse;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -17,21 +22,38 @@ import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Service
|
||||
public class GoodsOrderService {
|
||||
private static final String BIZ_TYPE = "GOODS_ORDER";
|
||||
private final ProductService productService;
|
||||
private final OrderLogService orderLogService;
|
||||
private final GoodsOrderStore store;
|
||||
private final AtomicLong orderIds = new AtomicLong(1L);
|
||||
private final AtomicLong itemIds = new AtomicLong(1L);
|
||||
private final Map<Long, GoodsOrderResponse> orders = new LinkedHashMap<Long, GoodsOrderResponse>();
|
||||
|
||||
@Autowired
|
||||
public GoodsOrderService(ProductService productService,
|
||||
OrderLogService orderLogService,
|
||||
ObjectProvider<GoodsOrderStore> storeProvider) {
|
||||
this(productService, orderLogService, storeProvider.getIfAvailable());
|
||||
}
|
||||
|
||||
public GoodsOrderService(ProductService productService) {
|
||||
this(productService, new OrderLogService(), (GoodsOrderStore) null);
|
||||
}
|
||||
|
||||
public GoodsOrderService(ProductService productService, OrderLogService orderLogService, GoodsOrderStore store) {
|
||||
this.productService = productService;
|
||||
this.orderLogService = orderLogService;
|
||||
this.store = store;
|
||||
loadStoredOrders();
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse create(Long userId, CreateGoodsOrderRequest request) {
|
||||
if (request.getItems() == null || request.getItems().isEmpty()) {
|
||||
throw new BizException("订单商品不能为空");
|
||||
throw new BizException("order items cannot be empty");
|
||||
}
|
||||
if (request.getDeliveryMethod() != DeliveryMethod.SELF_PICKUP && request.getAddressId() == null) {
|
||||
throw new BizException("配送地址不能为空");
|
||||
throw new BizException("delivery address is required");
|
||||
}
|
||||
|
||||
Map<Long, Integer> skuQuantities = collectSkuQuantities(request);
|
||||
@@ -61,6 +83,8 @@ public class GoodsOrderService {
|
||||
order.setCreatedAt(LocalDateTime.now());
|
||||
order.setUpdatedAt(order.getCreatedAt());
|
||||
orders.put(order.getId(), order);
|
||||
writeLog(order, null, order.getStatus().name(), "CREATE", userId, "resident created goods order");
|
||||
persist(order);
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
@@ -68,6 +92,14 @@ public class GoodsOrderService {
|
||||
return copyOrder(requireOrder(id));
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse detailMine(Long userId, Long id) {
|
||||
return copyOrder(requireMine(userId, id));
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse detailAdmin(Long communityId, Long id) {
|
||||
return copyOrder(requireAdmin(communityId, id));
|
||||
}
|
||||
|
||||
public synchronized List<GoodsOrderResponse> listMine(Long userId) {
|
||||
List<GoodsOrderResponse> result = new ArrayList<GoodsOrderResponse>();
|
||||
for (GoodsOrderResponse order : orders.values()) {
|
||||
@@ -92,36 +124,80 @@ public class GoodsOrderService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public synchronized PageResponse<GoodsOrderResponse> listAdminPage(Long communityId,
|
||||
GoodsOrderStatus status,
|
||||
Integer pageNo,
|
||||
Integer pageSize) {
|
||||
return PageUtils.page(listAdmin(communityId, status), pageNo, pageSize);
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse confirm(Long id) {
|
||||
GoodsOrderResponse order = requireOrder(id);
|
||||
move(order, GoodsOrderStatus.PREPARING);
|
||||
move(order, GoodsOrderStatus.PREPARING, "CONFIRM", null, "admin confirmed goods order");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse confirmAdmin(Long communityId, Long id) {
|
||||
return confirmAdmin(communityId, id, null);
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse confirmAdmin(Long communityId, Long id, Long operatorId) {
|
||||
GoodsOrderResponse order = requireAdmin(communityId, id);
|
||||
move(order, GoodsOrderStatus.PREPARING, "CONFIRM", operatorId, "admin confirmed goods order");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse markPrepared(Long id) {
|
||||
GoodsOrderResponse order = requireOrder(id);
|
||||
GoodsOrderStatus target = DeliveryMethod.SELF_PICKUP.equals(order.getDeliveryMethod())
|
||||
? GoodsOrderStatus.PENDING_PICKUP
|
||||
: GoodsOrderStatus.PENDING_DELIVERY;
|
||||
move(order, target);
|
||||
move(order, preparedTarget(order), "PREPARED", null, "merchant prepared goods order");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse markPreparedAdmin(Long communityId, Long id) {
|
||||
GoodsOrderResponse order = requireAdmin(communityId, id);
|
||||
move(order, preparedTarget(order), "PREPARED", null, "merchant prepared goods order");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse markDelivering(Long id) {
|
||||
GoodsOrderResponse order = requireOrder(id);
|
||||
move(order, GoodsOrderStatus.DELIVERING);
|
||||
move(order, GoodsOrderStatus.DELIVERING, "DELIVERING", null, "rider started delivery");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse markDeliveringAdmin(Long communityId, Long id) {
|
||||
GoodsOrderResponse order = requireAdmin(communityId, id);
|
||||
move(order, GoodsOrderStatus.DELIVERING, "DELIVERING", null, "rider started delivery");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse complete(Long id) {
|
||||
GoodsOrderResponse order = requireOrder(id);
|
||||
move(order, GoodsOrderStatus.COMPLETED);
|
||||
move(order, GoodsOrderStatus.COMPLETED, "COMPLETE", null, "goods order completed");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse completeAdmin(Long communityId, Long id) {
|
||||
GoodsOrderResponse order = requireAdmin(communityId, id);
|
||||
move(order, GoodsOrderStatus.COMPLETED, "COMPLETE", null, "goods order completed");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse cancel(Long id) {
|
||||
GoodsOrderResponse order = requireOrder(id);
|
||||
move(order, GoodsOrderStatus.CANCELED);
|
||||
move(order, GoodsOrderStatus.CANCELED, "CANCEL", null, "goods order canceled");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse cancelMine(Long userId, Long id) {
|
||||
GoodsOrderResponse order = requireMine(userId, id);
|
||||
move(order, GoodsOrderStatus.CANCELED, "CANCEL", userId, "resident canceled goods order");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse cancelAdmin(Long communityId, Long id) {
|
||||
GoodsOrderResponse order = requireAdmin(communityId, id);
|
||||
move(order, GoodsOrderStatus.CANCELED, "CANCEL", null, "admin canceled goods order");
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
@@ -129,17 +205,45 @@ public class GoodsOrderService {
|
||||
GoodsOrderResponse order = requireOrder(id);
|
||||
order.setOfflinePayStatus(OfflinePayStatus.PAID);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
writeLog(order, null, null, "OFFLINE_PAY", null, "goods order marked paid offline");
|
||||
persist(order);
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
public synchronized GoodsOrderResponse markPaidAdmin(Long communityId, Long id) {
|
||||
GoodsOrderResponse order = requireAdmin(communityId, id);
|
||||
order.setOfflinePayStatus(OfflinePayStatus.PAID);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
writeLog(order, null, null, "OFFLINE_PAY", null, "goods order marked paid offline");
|
||||
persist(order);
|
||||
return copyOrder(order);
|
||||
}
|
||||
|
||||
private void loadStoredOrders() {
|
||||
if (store == null) {
|
||||
return;
|
||||
}
|
||||
long maxOrderId = 0L;
|
||||
long maxItemId = 0L;
|
||||
for (GoodsOrderResponse order : store.loadAll()) {
|
||||
orders.put(order.getId(), copyOrder(order));
|
||||
maxOrderId = Math.max(maxOrderId, order.getId());
|
||||
for (GoodsOrderItemResponse item : order.getItems()) {
|
||||
maxItemId = Math.max(maxItemId, item.getId());
|
||||
}
|
||||
}
|
||||
orderIds.set(maxOrderId + 1L);
|
||||
itemIds.set(maxItemId + 1L);
|
||||
}
|
||||
|
||||
private Map<Long, Integer> collectSkuQuantities(CreateGoodsOrderRequest request) {
|
||||
Map<Long, Integer> skuQuantities = new LinkedHashMap<Long, Integer>();
|
||||
for (CreateGoodsOrderItemRequest item : request.getItems()) {
|
||||
if (item.getSkuId() == null || item.getProductId() == null) {
|
||||
throw new BizException("订单商品信息不完整");
|
||||
throw new BizException("order item is incomplete");
|
||||
}
|
||||
if (item.getQuantity() <= 0) {
|
||||
throw new BizException("购买数量必须大于0");
|
||||
throw new BizException("quantity must be positive");
|
||||
}
|
||||
Integer existing = skuQuantities.get(item.getSkuId());
|
||||
skuQuantities.put(item.getSkuId(), (existing == null ? 0 : existing) + item.getQuantity());
|
||||
@@ -152,10 +256,10 @@ public class GoodsOrderService {
|
||||
for (Map.Entry<Long, Integer> entry : skuQuantities.entrySet()) {
|
||||
ProductSkuResponse sku = productService.getSku(entry.getKey());
|
||||
if (!sku.isEnabled()) {
|
||||
throw new BizException("商品规格已下架");
|
||||
throw new BizException("sku is disabled");
|
||||
}
|
||||
if (sku.getStock() < entry.getValue()) {
|
||||
throw new BizException("库存不足");
|
||||
throw new BizException("stock is insufficient");
|
||||
}
|
||||
snapshots.put(entry.getKey(), sku);
|
||||
}
|
||||
@@ -198,18 +302,72 @@ public class GoodsOrderService {
|
||||
return 300;
|
||||
}
|
||||
|
||||
private void move(GoodsOrderResponse order, GoodsOrderStatus target) {
|
||||
if (!order.getStatus().canMoveTo(target)) {
|
||||
throw new BizException("订单状态不允许操作");
|
||||
private GoodsOrderStatus preparedTarget(GoodsOrderResponse order) {
|
||||
return DeliveryMethod.SELF_PICKUP.equals(order.getDeliveryMethod())
|
||||
? GoodsOrderStatus.PENDING_PICKUP
|
||||
: GoodsOrderStatus.PENDING_DELIVERY;
|
||||
}
|
||||
|
||||
private void move(GoodsOrderResponse order,
|
||||
GoodsOrderStatus target,
|
||||
String action,
|
||||
Long operatorId,
|
||||
String note) {
|
||||
if (!order.getStatus().canMoveTo(target)) {
|
||||
throw new BizException("order status does not allow this action");
|
||||
}
|
||||
GoodsOrderStatus from = order.getStatus();
|
||||
order.setStatus(target);
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
writeLog(order, from.name(), target.name(), action, operatorId, note);
|
||||
persist(order);
|
||||
}
|
||||
|
||||
private void writeLog(GoodsOrderResponse order,
|
||||
String fromStatus,
|
||||
String toStatus,
|
||||
String action,
|
||||
Long operatorId,
|
||||
String note) {
|
||||
orderLogService.record(
|
||||
order.getTenantId(),
|
||||
order.getCommunityId(),
|
||||
BIZ_TYPE,
|
||||
order.getId(),
|
||||
fromStatus,
|
||||
toStatus,
|
||||
action,
|
||||
operatorId,
|
||||
note
|
||||
);
|
||||
}
|
||||
|
||||
private void persist(GoodsOrderResponse order) {
|
||||
if (store != null) {
|
||||
store.save(copyOrder(order));
|
||||
}
|
||||
}
|
||||
|
||||
private GoodsOrderResponse requireOrder(Long id) {
|
||||
GoodsOrderResponse order = orders.get(id);
|
||||
if (order == null) {
|
||||
throw new BizException(404, "订单不存在");
|
||||
throw new BizException(404, "goods order not found");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
private GoodsOrderResponse requireMine(Long userId, Long id) {
|
||||
GoodsOrderResponse order = requireOrder(id);
|
||||
if (!userId.equals(order.getUserId())) {
|
||||
throw new BizException(404, "goods order not found");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
private GoodsOrderResponse requireAdmin(Long communityId, Long id) {
|
||||
GoodsOrderResponse order = requireOrder(id);
|
||||
if (!communityId.equals(order.getCommunityId())) {
|
||||
throw new BizException(404, "goods order not found");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
@@ -239,6 +397,9 @@ public class GoodsOrderService {
|
||||
|
||||
private List<GoodsOrderItemResponse> copyItems(List<GoodsOrderItemResponse> sourceItems) {
|
||||
List<GoodsOrderItemResponse> items = new ArrayList<GoodsOrderItemResponse>();
|
||||
if (sourceItems == null) {
|
||||
return items;
|
||||
}
|
||||
for (GoodsOrderItemResponse source : sourceItems) {
|
||||
GoodsOrderItemResponse target = new GoodsOrderItemResponse();
|
||||
target.setId(source.getId());
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.linhelp.order;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface GoodsOrderStore {
|
||||
List<GoodsOrderResponse> loadAll();
|
||||
|
||||
void save(GoodsOrderResponse order);
|
||||
}
|
||||
185
backend/src/main/java/com/linhelp/order/JdbcGoodsOrderStore.java
Normal file
185
backend/src/main/java/com/linhelp/order/JdbcGoodsOrderStore.java
Normal file
@@ -0,0 +1,185 @@
|
||||
package com.linhelp.order;
|
||||
|
||||
import com.linhelp.common.enums.DeliveryMethod;
|
||||
import com.linhelp.common.enums.GoodsOrderStatus;
|
||||
import com.linhelp.common.enums.OfflinePayStatus;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
@ConditionalOnBean(JdbcTemplate.class)
|
||||
public class JdbcGoodsOrderStore implements GoodsOrderStore {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public JdbcGoodsOrderStore(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GoodsOrderResponse> loadAll() {
|
||||
List<GoodsOrderResponse> orders = jdbcTemplate.query(
|
||||
"SELECT id, order_no, tenant_id, community_id, user_id, merchant_id, address_id, delivery_method, scheduled_time, " +
|
||||
"status, offline_pay_status, total_amount_cent, delivery_fee_cent, payable_amount_cent, remark, cancel_reason, created_at, updated_at " +
|
||||
"FROM goods_order WHERE deleted = 0 ORDER BY id ASC",
|
||||
this::mapOrder
|
||||
);
|
||||
Map<Long, GoodsOrderResponse> byId = new LinkedHashMap<Long, GoodsOrderResponse>();
|
||||
for (GoodsOrderResponse order : orders) {
|
||||
byId.put(order.getId(), order);
|
||||
}
|
||||
if (!byId.isEmpty()) {
|
||||
List<GoodsOrderItemResponse> items = jdbcTemplate.query(
|
||||
"SELECT id, order_id, product_id, sku_id, product_name, sku_name, quantity, price_cent, amount_cent " +
|
||||
"FROM goods_order_item WHERE order_id IN (" + placeholders(byId.size()) + ") ORDER BY id ASC",
|
||||
this::mapItem,
|
||||
byId.keySet().toArray()
|
||||
);
|
||||
for (GoodsOrderItemResponse item : items) {
|
||||
GoodsOrderResponse order = byId.get(item.getOrderId());
|
||||
if (order != null) {
|
||||
order.getItems().add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<GoodsOrderResponse>(byId.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(GoodsOrderResponse order) {
|
||||
int updated = jdbcTemplate.update(
|
||||
"UPDATE goods_order SET order_no = ?, tenant_id = ?, community_id = ?, user_id = ?, merchant_id = ?, address_id = ?, " +
|
||||
"delivery_method = ?, scheduled_time = ?, status = ?, offline_pay_status = ?, total_amount_cent = ?, delivery_fee_cent = ?, " +
|
||||
"payable_amount_cent = ?, remark = ?, updated_at = ? WHERE id = ?",
|
||||
order.getOrderNo(),
|
||||
order.getTenantId(),
|
||||
order.getCommunityId(),
|
||||
order.getUserId(),
|
||||
order.getMerchantId(),
|
||||
order.getAddressId(),
|
||||
order.getDeliveryMethod().name(),
|
||||
toTimestamp(order.getScheduledTime()),
|
||||
order.getStatus().name(),
|
||||
order.getOfflinePayStatus().name(),
|
||||
order.getTotalAmountCent(),
|
||||
order.getDeliveryFeeCent(),
|
||||
order.getPayableAmountCent(),
|
||||
order.getRemark(),
|
||||
toTimestamp(order.getUpdatedAt()),
|
||||
order.getId()
|
||||
);
|
||||
if (updated == 0) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO goods_order (id, order_no, tenant_id, community_id, user_id, merchant_id, address_id, delivery_method, scheduled_time, " +
|
||||
"status, offline_pay_status, total_amount_cent, delivery_fee_cent, payable_amount_cent, remark, created_at, updated_at) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
order.getId(),
|
||||
order.getOrderNo(),
|
||||
order.getTenantId(),
|
||||
order.getCommunityId(),
|
||||
order.getUserId(),
|
||||
order.getMerchantId(),
|
||||
order.getAddressId(),
|
||||
order.getDeliveryMethod().name(),
|
||||
toTimestamp(order.getScheduledTime()),
|
||||
order.getStatus().name(),
|
||||
order.getOfflinePayStatus().name(),
|
||||
order.getTotalAmountCent(),
|
||||
order.getDeliveryFeeCent(),
|
||||
order.getPayableAmountCent(),
|
||||
order.getRemark(),
|
||||
toTimestamp(order.getCreatedAt()),
|
||||
toTimestamp(order.getUpdatedAt())
|
||||
);
|
||||
}
|
||||
replaceItems(order);
|
||||
}
|
||||
|
||||
private void replaceItems(GoodsOrderResponse order) {
|
||||
jdbcTemplate.update("DELETE FROM goods_order_item WHERE order_id = ?", order.getId());
|
||||
for (GoodsOrderItemResponse item : order.getItems()) {
|
||||
try {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO goods_order_item (id, tenant_id, community_id, order_id, product_id, sku_id, product_name, sku_name, quantity, price_cent, amount_cent) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
item.getId(),
|
||||
order.getTenantId(),
|
||||
order.getCommunityId(),
|
||||
order.getId(),
|
||||
item.getProductId(),
|
||||
item.getSkuId(),
|
||||
item.getProductName(),
|
||||
item.getSkuName(),
|
||||
item.getQuantity(),
|
||||
item.getPriceCent(),
|
||||
item.getAmountCent()
|
||||
);
|
||||
} catch (DataAccessException ex) {
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private GoodsOrderResponse mapOrder(ResultSet rs, int rowNum) throws SQLException {
|
||||
GoodsOrderResponse order = new GoodsOrderResponse();
|
||||
order.setId(rs.getLong("id"));
|
||||
order.setOrderNo(rs.getString("order_no"));
|
||||
order.setTenantId(rs.getLong("tenant_id"));
|
||||
order.setCommunityId(rs.getLong("community_id"));
|
||||
order.setUserId(rs.getLong("user_id"));
|
||||
order.setMerchantId(rs.getLong("merchant_id"));
|
||||
long addressId = rs.getLong("address_id");
|
||||
order.setAddressId(rs.wasNull() ? null : addressId);
|
||||
order.setDeliveryMethod(DeliveryMethod.valueOf(rs.getString("delivery_method")));
|
||||
Timestamp scheduledTime = rs.getTimestamp("scheduled_time");
|
||||
order.setScheduledTime(scheduledTime == null ? null : scheduledTime.toLocalDateTime());
|
||||
order.setStatus(GoodsOrderStatus.valueOf(rs.getString("status")));
|
||||
order.setOfflinePayStatus(OfflinePayStatus.valueOf(rs.getString("offline_pay_status")));
|
||||
order.setTotalAmountCent(rs.getInt("total_amount_cent"));
|
||||
order.setDeliveryFeeCent(rs.getInt("delivery_fee_cent"));
|
||||
order.setPayableAmountCent(rs.getInt("payable_amount_cent"));
|
||||
order.setRemark(rs.getString("remark"));
|
||||
order.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
|
||||
order.setUpdatedAt(rs.getTimestamp("updated_at").toLocalDateTime());
|
||||
return order;
|
||||
}
|
||||
|
||||
private GoodsOrderItemResponse mapItem(ResultSet rs, int rowNum) throws SQLException {
|
||||
GoodsOrderItemResponse item = new GoodsOrderItemResponse();
|
||||
item.setId(rs.getLong("id"));
|
||||
item.setOrderId(rs.getLong("order_id"));
|
||||
item.setProductId(rs.getLong("product_id"));
|
||||
item.setSkuId(rs.getLong("sku_id"));
|
||||
item.setProductName(rs.getString("product_name"));
|
||||
item.setSkuName(rs.getString("sku_name"));
|
||||
item.setQuantity(rs.getInt("quantity"));
|
||||
item.setPriceCent(rs.getInt("price_cent"));
|
||||
item.setAmountCent(rs.getInt("amount_cent"));
|
||||
return item;
|
||||
}
|
||||
|
||||
private String placeholders(int size) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < size; i++) {
|
||||
if (i > 0) {
|
||||
builder.append(',');
|
||||
}
|
||||
builder.append('?');
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private Timestamp toTimestamp(LocalDateTime value) {
|
||||
return value == null ? null : Timestamp.valueOf(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.linhelp.orderlog;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
@ConditionalOnBean(JdbcTemplate.class)
|
||||
public class JdbcOrderLogStore implements OrderLogStore {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public JdbcOrderLogStore(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(OrderLogRecord record) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO order_log (tenant_id, community_id, biz_type, biz_order_id, from_status, to_status, action, operator_id, note, created_at) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
record.getTenantId(),
|
||||
record.getCommunityId(),
|
||||
record.getBizType(),
|
||||
record.getBizOrderId(),
|
||||
record.getFromStatus(),
|
||||
record.getToStatus(),
|
||||
record.getAction(),
|
||||
record.getOperatorId(),
|
||||
record.getNote(),
|
||||
Timestamp.valueOf(record.getCreatedAt())
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<OrderLogRecord> list(String bizType, Long bizOrderId) {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT id, tenant_id, community_id, biz_type, biz_order_id, from_status, to_status, action, operator_id, note, created_at " +
|
||||
"FROM order_log WHERE biz_type = ? AND biz_order_id = ? ORDER BY id ASC",
|
||||
this::mapRecord,
|
||||
bizType,
|
||||
bizOrderId
|
||||
);
|
||||
}
|
||||
|
||||
private OrderLogRecord mapRecord(ResultSet rs, int rowNum) throws SQLException {
|
||||
OrderLogRecord record = new OrderLogRecord();
|
||||
record.setId(rs.getLong("id"));
|
||||
record.setTenantId(rs.getLong("tenant_id"));
|
||||
record.setCommunityId(rs.getLong("community_id"));
|
||||
record.setBizType(rs.getString("biz_type"));
|
||||
record.setBizOrderId(rs.getLong("biz_order_id"));
|
||||
record.setFromStatus(rs.getString("from_status"));
|
||||
record.setToStatus(rs.getString("to_status"));
|
||||
record.setAction(rs.getString("action"));
|
||||
long operatorId = rs.getLong("operator_id");
|
||||
record.setOperatorId(rs.wasNull() ? null : operatorId);
|
||||
record.setNote(rs.getString("note"));
|
||||
record.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
|
||||
return record;
|
||||
}
|
||||
}
|
||||
105
backend/src/main/java/com/linhelp/orderlog/OrderLogRecord.java
Normal file
105
backend/src/main/java/com/linhelp/orderlog/OrderLogRecord.java
Normal file
@@ -0,0 +1,105 @@
|
||||
package com.linhelp.orderlog;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public class OrderLogRecord {
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long communityId;
|
||||
private String bizType;
|
||||
private Long bizOrderId;
|
||||
private String fromStatus;
|
||||
private String toStatus;
|
||||
private String action;
|
||||
private Long operatorId;
|
||||
private String note;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getTenantId() {
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
public void setTenantId(Long tenantId) {
|
||||
this.tenantId = tenantId;
|
||||
}
|
||||
|
||||
public Long getCommunityId() {
|
||||
return communityId;
|
||||
}
|
||||
|
||||
public void setCommunityId(Long communityId) {
|
||||
this.communityId = communityId;
|
||||
}
|
||||
|
||||
public String getBizType() {
|
||||
return bizType;
|
||||
}
|
||||
|
||||
public void setBizType(String bizType) {
|
||||
this.bizType = bizType;
|
||||
}
|
||||
|
||||
public Long getBizOrderId() {
|
||||
return bizOrderId;
|
||||
}
|
||||
|
||||
public void setBizOrderId(Long bizOrderId) {
|
||||
this.bizOrderId = bizOrderId;
|
||||
}
|
||||
|
||||
public String getFromStatus() {
|
||||
return fromStatus;
|
||||
}
|
||||
|
||||
public void setFromStatus(String fromStatus) {
|
||||
this.fromStatus = fromStatus;
|
||||
}
|
||||
|
||||
public String getToStatus() {
|
||||
return toStatus;
|
||||
}
|
||||
|
||||
public void setToStatus(String toStatus) {
|
||||
this.toStatus = toStatus;
|
||||
}
|
||||
|
||||
public String getAction() {
|
||||
return action;
|
||||
}
|
||||
|
||||
public void setAction(String action) {
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public Long getOperatorId() {
|
||||
return operatorId;
|
||||
}
|
||||
|
||||
public void setOperatorId(Long operatorId) {
|
||||
this.operatorId = operatorId;
|
||||
}
|
||||
|
||||
public String getNote() {
|
||||
return note;
|
||||
}
|
||||
|
||||
public void setNote(String note) {
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.linhelp.orderlog;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Service
|
||||
public class OrderLogService {
|
||||
private final AtomicLong ids = new AtomicLong(1L);
|
||||
private final Map<String, List<OrderLogRecord>> records = new LinkedHashMap<String, List<OrderLogRecord>>();
|
||||
private final OrderLogStore store;
|
||||
|
||||
public OrderLogService(ObjectProvider<OrderLogStore> storeProvider) {
|
||||
this(storeProvider.getIfAvailable());
|
||||
}
|
||||
|
||||
public OrderLogService() {
|
||||
this((OrderLogStore) null);
|
||||
}
|
||||
|
||||
public OrderLogService(OrderLogStore store) {
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
public synchronized OrderLogRecord record(Long tenantId,
|
||||
Long communityId,
|
||||
String bizType,
|
||||
Long bizOrderId,
|
||||
String fromStatus,
|
||||
String toStatus,
|
||||
String action,
|
||||
Long operatorId,
|
||||
String note) {
|
||||
OrderLogRecord record = new OrderLogRecord();
|
||||
record.setId(ids.getAndIncrement());
|
||||
record.setTenantId(tenantId);
|
||||
record.setCommunityId(communityId);
|
||||
record.setBizType(bizType);
|
||||
record.setBizOrderId(bizOrderId);
|
||||
record.setFromStatus(fromStatus);
|
||||
record.setToStatus(toStatus);
|
||||
record.setAction(action);
|
||||
record.setOperatorId(operatorId);
|
||||
record.setNote(note);
|
||||
record.setCreatedAt(LocalDateTime.now());
|
||||
if (store != null) {
|
||||
store.save(copy(record));
|
||||
} else {
|
||||
List<OrderLogRecord> bucket = records.get(key(bizType, bizOrderId));
|
||||
if (bucket == null) {
|
||||
bucket = new ArrayList<OrderLogRecord>();
|
||||
records.put(key(bizType, bizOrderId), bucket);
|
||||
}
|
||||
bucket.add(copy(record));
|
||||
}
|
||||
return copy(record);
|
||||
}
|
||||
|
||||
public synchronized List<OrderLogRecord> list(String bizType, Long bizOrderId) {
|
||||
if (store != null) {
|
||||
return store.list(bizType, bizOrderId);
|
||||
}
|
||||
List<OrderLogRecord> bucket = records.get(key(bizType, bizOrderId));
|
||||
if (bucket == null) {
|
||||
return new ArrayList<OrderLogRecord>();
|
||||
}
|
||||
List<OrderLogRecord> result = new ArrayList<OrderLogRecord>();
|
||||
for (OrderLogRecord record : bucket) {
|
||||
result.add(copy(record));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String key(String bizType, Long bizOrderId) {
|
||||
return bizType + "#" + bizOrderId;
|
||||
}
|
||||
|
||||
private OrderLogRecord copy(OrderLogRecord source) {
|
||||
OrderLogRecord target = new OrderLogRecord();
|
||||
target.setId(source.getId());
|
||||
target.setTenantId(source.getTenantId());
|
||||
target.setCommunityId(source.getCommunityId());
|
||||
target.setBizType(source.getBizType());
|
||||
target.setBizOrderId(source.getBizOrderId());
|
||||
target.setFromStatus(source.getFromStatus());
|
||||
target.setToStatus(source.getToStatus());
|
||||
target.setAction(source.getAction());
|
||||
target.setOperatorId(source.getOperatorId());
|
||||
target.setNote(source.getNote());
|
||||
target.setCreatedAt(source.getCreatedAt());
|
||||
return target;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.linhelp.orderlog;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface OrderLogStore {
|
||||
void save(OrderLogRecord record);
|
||||
|
||||
List<OrderLogRecord> list(String bizType, Long bizOrderId);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.linhelp.product;
|
||||
|
||||
import com.linhelp.common.api.ApiResponse;
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.security.AuthService;
|
||||
import com.linhelp.common.security.CurrentUser;
|
||||
import com.linhelp.common.security.RequireRole;
|
||||
@@ -70,6 +71,17 @@ public class ProductController {
|
||||
return ApiResponse.ok(productService.listAdmin(user.getCommunityId(), merchantId, categoryId));
|
||||
}
|
||||
|
||||
@GetMapping("/api/admin/products/page")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<PageResponse<ProductResponse>> pageAdminProducts(@RequestParam(required = false) Long merchantId,
|
||||
@RequestParam(required = false) Long categoryId,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Integer pageNo,
|
||||
@RequestParam(required = false) Integer pageSize) {
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(productService.listAdminPage(user.getCommunityId(), merchantId, categoryId, keyword, pageNo, pageSize));
|
||||
}
|
||||
|
||||
@PostMapping("/api/admin/products")
|
||||
@RequireRole({"PLATFORM_ADMIN", "COMMUNITY_ADMIN", "MERCHANT"})
|
||||
public ApiResponse<ProductResponse> createProduct(@Valid @RequestBody ProductRequest request) {
|
||||
@@ -144,8 +156,18 @@ public class ProductController {
|
||||
return ApiResponse.ok(productService.listMini(user.getCommunityId(), categoryId));
|
||||
}
|
||||
|
||||
@GetMapping("/api/mini/products/page")
|
||||
public ApiResponse<PageResponse<ProductResponse>> pageMiniProducts(@RequestParam(required = false) Long categoryId,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Integer pageNo,
|
||||
@RequestParam(required = false) Integer pageSize) {
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(productService.listMiniPage(user.getCommunityId(), categoryId, keyword, pageNo, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/api/mini/products/{id}")
|
||||
public ApiResponse<ProductResponse> miniProductDetail(@PathVariable Long id) {
|
||||
return ApiResponse.ok(productService.detail(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(productService.detailMini(user.getCommunityId(), id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.linhelp.product;
|
||||
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.api.PageUtils;
|
||||
import com.linhelp.common.exception.BizException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -116,14 +118,39 @@ public class ProductService {
|
||||
return copyProduct(requireProduct(id));
|
||||
}
|
||||
|
||||
public synchronized ProductResponse detailMini(Long communityId, Long id) {
|
||||
ProductResponse product = requireProduct(id);
|
||||
if (!communityId.equals(product.getCommunityId()) || !STATUS_ON_SHELF.equals(product.getStatus())) {
|
||||
throw new BizException(404, "商品不存在");
|
||||
}
|
||||
return copyProduct(product);
|
||||
}
|
||||
|
||||
public synchronized List<ProductResponse> listAdmin(Long communityId, Long merchantId, Long categoryId) {
|
||||
return listProducts(communityId, merchantId, categoryId, false);
|
||||
}
|
||||
|
||||
public synchronized PageResponse<ProductResponse> listAdminPage(Long communityId,
|
||||
Long merchantId,
|
||||
Long categoryId,
|
||||
String keyword,
|
||||
Integer pageNo,
|
||||
Integer pageSize) {
|
||||
return PageUtils.page(listProducts(communityId, merchantId, categoryId, false, keyword), pageNo, pageSize);
|
||||
}
|
||||
|
||||
public synchronized List<ProductResponse> listMini(Long communityId, Long categoryId) {
|
||||
return listProducts(communityId, null, categoryId, true);
|
||||
}
|
||||
|
||||
public synchronized PageResponse<ProductResponse> listMiniPage(Long communityId,
|
||||
Long categoryId,
|
||||
String keyword,
|
||||
Integer pageNo,
|
||||
Integer pageSize) {
|
||||
return PageUtils.page(listProducts(communityId, null, categoryId, true, keyword), pageNo, pageSize);
|
||||
}
|
||||
|
||||
public synchronized ProductSkuResponse addSku(Long productId, ProductSkuRequest request) {
|
||||
ProductResponse product = requireProduct(productId);
|
||||
ProductSkuResponse sku = new ProductSkuResponse();
|
||||
@@ -207,6 +234,10 @@ public class ProductService {
|
||||
}
|
||||
|
||||
private List<ProductResponse> listProducts(Long communityId, Long merchantId, Long categoryId, boolean onlyOnShelf) {
|
||||
return listProducts(communityId, merchantId, categoryId, onlyOnShelf, null);
|
||||
}
|
||||
|
||||
private List<ProductResponse> listProducts(Long communityId, Long merchantId, Long categoryId, boolean onlyOnShelf, String keyword) {
|
||||
List<ProductResponse> result = new ArrayList<ProductResponse>();
|
||||
for (ProductResponse product : products.values()) {
|
||||
if (!communityId.equals(product.getCommunityId())) {
|
||||
@@ -221,11 +252,26 @@ public class ProductService {
|
||||
if (onlyOnShelf && !STATUS_ON_SHELF.equals(product.getStatus())) {
|
||||
continue;
|
||||
}
|
||||
if (!matchesKeyword(product, keyword)) {
|
||||
continue;
|
||||
}
|
||||
result.add(copyProduct(product));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean matchesKeyword(ProductResponse product, String keyword) {
|
||||
if (keyword == null || keyword.trim().isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
String normalized = keyword.trim().toLowerCase();
|
||||
return contains(product.getName(), normalized) || contains(product.getDescription(), normalized);
|
||||
}
|
||||
|
||||
private boolean contains(String value, String keyword) {
|
||||
return value != null && value.toLowerCase().contains(keyword);
|
||||
}
|
||||
|
||||
private ProductCategoryResponse requireCategory(Long id) {
|
||||
ProductCategoryResponse category = categories.get(id);
|
||||
if (category == null) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.linhelp.secondhand;
|
||||
|
||||
import com.linhelp.common.api.ApiResponse;
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.enums.SecondGoodsStatus;
|
||||
import com.linhelp.common.security.AuthService;
|
||||
import com.linhelp.common.security.CurrentUser;
|
||||
@@ -57,9 +58,19 @@ public class SecondGoodsController {
|
||||
return ApiResponse.ok(secondGoodsService.listPublic(user.getCommunityId(), category));
|
||||
}
|
||||
|
||||
@GetMapping("/api/mini/second-goods/page")
|
||||
public ApiResponse<PageResponse<SecondGoodsResponse>> pagePublic(@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Integer pageNo,
|
||||
@RequestParam(required = false) Integer pageSize) {
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(secondGoodsService.listPublicPage(user.getCommunityId(), category, keyword, pageNo, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/api/mini/second-goods/{id}")
|
||||
public ApiResponse<SecondGoodsResponse> detail(@PathVariable Long id) {
|
||||
return ApiResponse.ok(secondGoodsService.detail(id));
|
||||
CurrentUser user = authService.currentUser();
|
||||
return ApiResponse.ok(secondGoodsService.detailPublic(user.getCommunityId(), id));
|
||||
}
|
||||
|
||||
@PostMapping("/api/mini/second-goods")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.linhelp.secondhand;
|
||||
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.api.PageUtils;
|
||||
import com.linhelp.common.enums.SecondGoodsStatus;
|
||||
import com.linhelp.common.exception.BizException;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -47,6 +49,30 @@ public class SecondGoodsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public synchronized PageResponse<SecondGoodsResponse> listPublicPage(Long communityId,
|
||||
String category,
|
||||
String keyword,
|
||||
Integer pageNo,
|
||||
Integer pageSize) {
|
||||
List<SecondGoodsResponse> result = new ArrayList<SecondGoodsResponse>();
|
||||
for (SecondGoodsResponse item : goods.values()) {
|
||||
if (!communityId.equals(item.getCommunityId())) {
|
||||
continue;
|
||||
}
|
||||
if (category != null && !category.equals(item.getCategory())) {
|
||||
continue;
|
||||
}
|
||||
if (SecondGoodsStatus.PUBLISHED != item.getStatus()) {
|
||||
continue;
|
||||
}
|
||||
if (!matchesKeyword(item, keyword)) {
|
||||
continue;
|
||||
}
|
||||
result.add(copy(item));
|
||||
}
|
||||
return PageUtils.page(result, pageNo, pageSize);
|
||||
}
|
||||
|
||||
public synchronized List<SecondGoodsResponse> listAdmin(Long communityId, SecondGoodsStatus status) {
|
||||
List<SecondGoodsResponse> result = new ArrayList<SecondGoodsResponse>();
|
||||
for (SecondGoodsResponse item : goods.values()) {
|
||||
@@ -65,6 +91,14 @@ public class SecondGoodsService {
|
||||
return copy(require(id));
|
||||
}
|
||||
|
||||
public synchronized SecondGoodsResponse detailPublic(Long communityId, Long id) {
|
||||
SecondGoodsResponse item = require(id);
|
||||
if (!communityId.equals(item.getCommunityId()) || SecondGoodsStatus.PUBLISHED != item.getStatus()) {
|
||||
throw new BizException(404, "闲置不存在");
|
||||
}
|
||||
return copy(item);
|
||||
}
|
||||
|
||||
public synchronized SecondGoodsResponse approve(Long id) {
|
||||
SecondGoodsResponse item = require(id);
|
||||
item.setStatus(SecondGoodsStatus.PUBLISHED);
|
||||
@@ -136,6 +170,20 @@ public class SecondGoodsService {
|
||||
return item;
|
||||
}
|
||||
|
||||
private boolean matchesKeyword(SecondGoodsResponse item, String keyword) {
|
||||
if (keyword == null || keyword.trim().isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
String normalized = keyword.trim().toLowerCase();
|
||||
return contains(item.getTitle(), normalized)
|
||||
|| contains(item.getDescription(), normalized)
|
||||
|| contains(item.getCategory(), normalized);
|
||||
}
|
||||
|
||||
private boolean contains(String value, String keyword) {
|
||||
return value != null && value.toLowerCase().contains(keyword);
|
||||
}
|
||||
|
||||
private void copyFields(SecondGoodsRequest request, SecondGoodsResponse response) {
|
||||
response.setTitle(request.getTitle());
|
||||
response.setImageUrls(request.getImageUrls() == null
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package com.linhelp.storage;
|
||||
|
||||
import com.linhelp.common.exception.BizException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
@@ -11,11 +18,30 @@ import java.util.UUID;
|
||||
@Service
|
||||
public class FileStorageService {
|
||||
private static final DateTimeFormatter DATE_PATH_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM/dd");
|
||||
private final Path uploadRoot;
|
||||
private final String publicPrefix;
|
||||
private final long maxSizeBytes;
|
||||
|
||||
@Autowired
|
||||
public FileStorageService(@Value("${linhelp.storage.local-root:uploads}") String localRoot,
|
||||
@Value("${linhelp.storage.public-prefix:/uploads}") String publicPrefix,
|
||||
@Value("${linhelp.storage.max-size-bytes:5242880}") long maxSizeBytes) {
|
||||
this(Paths.get(localRoot), publicPrefix, maxSizeBytes);
|
||||
}
|
||||
|
||||
public FileStorageService(Path uploadRoot, String publicPrefix, long maxSizeBytes) {
|
||||
this.uploadRoot = uploadRoot.toAbsolutePath().normalize();
|
||||
this.publicPrefix = normalizePublicPrefix(publicPrefix);
|
||||
this.maxSizeBytes = maxSizeBytes;
|
||||
}
|
||||
|
||||
public FileUploadResponse upload(Long tenantId, Long communityId, MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BizException("文件不能为空");
|
||||
}
|
||||
if (file.getSize() > maxSizeBytes) {
|
||||
throw new BizException("文件大小不能超过" + maxSizeBytes + "字节");
|
||||
}
|
||||
String contentType = file.getContentType();
|
||||
if (contentType == null || !contentType.startsWith("image/")) {
|
||||
throw new BizException("仅支持图片上传");
|
||||
@@ -28,16 +54,43 @@ public class FileStorageService {
|
||||
+ "/" + DATE_PATH_FORMATTER.format(LocalDate.now())
|
||||
+ "/" + UUID.randomUUID().toString().replace("-", "")
|
||||
+ extension;
|
||||
writeFile(objectKey, file);
|
||||
|
||||
FileUploadResponse response = new FileUploadResponse();
|
||||
response.setOriginalFilename(originalFilename);
|
||||
response.setContentType(contentType);
|
||||
response.setSize(file.getSize());
|
||||
response.setObjectKey(objectKey);
|
||||
response.setUrl("/uploads/" + objectKey);
|
||||
response.setUrl(publicPrefix + "/" + objectKey);
|
||||
return response;
|
||||
}
|
||||
|
||||
private void writeFile(String objectKey, MultipartFile file) {
|
||||
Path target = uploadRoot.resolve(objectKey).normalize();
|
||||
if (!target.startsWith(uploadRoot)) {
|
||||
throw new BizException("文件路径非法");
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(target.getParent());
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
Files.copy(inputStream, target);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new BizException("文件保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizePublicPrefix(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return "/uploads";
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
while (trimmed.endsWith("/")) {
|
||||
trimmed = trimmed.substring(0, trimmed.length() - 1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private String extensionOf(String filename) {
|
||||
if (filename == null) {
|
||||
return "";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.linhelp.storage;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.nio.file.Paths;
|
||||
|
||||
@Configuration
|
||||
public class StorageWebConfig implements WebMvcConfigurer {
|
||||
private final String localRoot;
|
||||
|
||||
public StorageWebConfig(@Value("${linhelp.storage.local-root:uploads}") String localRoot) {
|
||||
this.localRoot = localRoot;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
String location = Paths.get(localRoot).toAbsolutePath().normalize().toUri().toString();
|
||||
registry.addResourceHandler("/uploads/**")
|
||||
.addResourceLocations(location);
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,18 @@ sa-token:
|
||||
timeout: 2592000
|
||||
|
||||
linhelp:
|
||||
auth:
|
||||
demo-admin-enabled: true
|
||||
dev-miniapp-login-enabled: true
|
||||
storage:
|
||||
endpoint: http://localhost:9000
|
||||
access-key: linhelp
|
||||
secret-key: linhelp123
|
||||
bucket: linhelp
|
||||
local-root: uploads
|
||||
public-prefix: /uploads
|
||||
max-size-bytes: 5242880
|
||||
wechat:
|
||||
miniapp:
|
||||
app-id:
|
||||
app-secret:
|
||||
|
||||
@@ -19,8 +19,18 @@ sa-token:
|
||||
timeout: 2592000
|
||||
|
||||
linhelp:
|
||||
auth:
|
||||
demo-admin-enabled: true
|
||||
dev-miniapp-login-enabled: true
|
||||
storage:
|
||||
endpoint: http://localhost:9000
|
||||
access-key: linhelp
|
||||
secret-key: linhelp123
|
||||
bucket: linhelp
|
||||
local-root: uploads
|
||||
public-prefix: /uploads
|
||||
max-size-bytes: 5242880
|
||||
wechat:
|
||||
miniapp:
|
||||
app-id:
|
||||
app-secret:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
ALTER TABLE goods_order
|
||||
ADD COLUMN delivery_fee_cent INT NOT NULL DEFAULT 0 AFTER total_amount_cent,
|
||||
ADD COLUMN payable_amount_cent INT NOT NULL DEFAULT 0 AFTER delivery_fee_cent;
|
||||
|
||||
UPDATE goods_order
|
||||
SET payable_amount_cent = total_amount_cent + delivery_fee_cent
|
||||
WHERE payable_amount_cent = 0;
|
||||
|
||||
ALTER TABLE express_order
|
||||
ADD COLUMN contact_name VARCHAR(40) AFTER address_id,
|
||||
ADD COLUMN contact_phone VARCHAR(32) AFTER contact_name,
|
||||
ADD COLUMN delivery_address VARCHAR(255) AFTER contact_phone,
|
||||
ADD COLUMN receiver_phone VARCHAR(32) AFTER delivery_address,
|
||||
ADD COLUMN delivered_photo_url VARCHAR(255) AFTER image_url;
|
||||
|
||||
UPDATE express_order
|
||||
SET receiver_phone = phone
|
||||
WHERE receiver_phone IS NULL;
|
||||
|
||||
ALTER TABLE group_buy_order
|
||||
ADD COLUMN group_buy_title VARCHAR(120) AFTER group_buy_id,
|
||||
ADD COLUMN cover_url VARCHAR(255) AFTER group_buy_title,
|
||||
ADD COLUMN price_cent INT NOT NULL DEFAULT 0 AFTER quantity;
|
||||
|
||||
ALTER TABLE delivery_order
|
||||
ADD COLUMN biz_order_no VARCHAR(40) AFTER biz_order_id,
|
||||
ADD COLUMN rider_name VARCHAR(40) AFTER delivery_user_id,
|
||||
ADD COLUMN contact_name VARCHAR(40) AFTER rider_name,
|
||||
ADD COLUMN contact_phone VARCHAR(32) AFTER contact_name,
|
||||
ADD COLUMN address VARCHAR(255) AFTER contact_phone,
|
||||
ADD COLUMN amount_cent INT NOT NULL DEFAULT 0 AFTER address,
|
||||
ADD COLUMN remark VARCHAR(255) AFTER amount_cent;
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE admin_user (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
community_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
username VARCHAR(80) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role_code VARCHAR(40) NOT NULL,
|
||||
enabled TINYINT NOT NULL DEFAULT 1,
|
||||
last_login_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted TINYINT NOT NULL DEFAULT 0,
|
||||
UNIQUE KEY uk_admin_user_username (username),
|
||||
INDEX idx_admin_user_scope (tenant_id, community_id),
|
||||
INDEX idx_admin_user_user (user_id)
|
||||
);
|
||||
|
||||
INSERT INTO admin_user (id, tenant_id, community_id, user_id, username, password_hash, role_code, enabled)
|
||||
VALUES
|
||||
(1, 1, 1, 1, 'admin', 'pbkdf2_sha256$60000$YWRtaW4tbGluaGVscC1zZWVk$pJ+k3zLTWCAkEuUtCcgAPgxw3rSF0X3LBBiRYIKNKpA=', 'COMMUNITY_ADMIN', 1),
|
||||
(2, 1, 1, 2, 'merchant', 'pbkdf2_sha256$60000$bWVyY2hhbnQtbGluaGVscC1zZWVk$Nu4Mudpu2s7kQyKeRe3sNYv2+BCGobLiqYFGCZkJgig=', 'MERCHANT', 1),
|
||||
(3, 1, 1, 3, 'rider', 'pbkdf2_sha256$60000$cmlkZXItbGluaGVscC1zZWVk$v/mQtofZuNaOmG/+xi1+HORpPuha3K714cSm58sYeQE=', 'RIDER', 1);
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.linhelp.common.security;
|
||||
|
||||
import com.linhelp.common.exception.BizException;
|
||||
import com.linhelp.common.security.account.AdminAccount;
|
||||
import com.linhelp.common.security.account.AdminAccountRepository;
|
||||
import com.linhelp.common.security.account.PasswordHasher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class AuthServiceTests {
|
||||
@Test
|
||||
void devMiniappLoginIsRejectedWhenDisabled() {
|
||||
AuthService authService = new AuthService(new StubMiniappSessionClient("openid-a"), new StubLoginSessionIssuer(), true, false);
|
||||
|
||||
LoginRequest request = new LoginRequest();
|
||||
request.setUsername("resident");
|
||||
request.setPassword("dev");
|
||||
|
||||
assertThatThrownBy(() -> authService.devMiniappLogin(request))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("disabled");
|
||||
}
|
||||
|
||||
@Test
|
||||
void demoAdminLoginIsRejectedWhenDisabled() {
|
||||
AuthService authService = new AuthService(new StubMiniappSessionClient("openid-a"), new StubLoginSessionIssuer(), false, true);
|
||||
|
||||
LoginRequest request = new LoginRequest();
|
||||
request.setUsername("admin");
|
||||
request.setPassword("admin123");
|
||||
|
||||
assertThatThrownBy(() -> authService.adminLogin(request))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("disabled");
|
||||
}
|
||||
|
||||
@Test
|
||||
void miniappLoginCreatesStableResidentUserByOpenId() {
|
||||
AuthService authService = new AuthService(new StubMiniappSessionClient("openid-a"), new StubLoginSessionIssuer(), false, false);
|
||||
MiniappLoginRequest request = new MiniappLoginRequest();
|
||||
request.setCode("wx-code");
|
||||
|
||||
LoginResponse first = authService.miniappLogin(request);
|
||||
LoginResponse second = authService.miniappLogin(request);
|
||||
|
||||
assertThat(first.getUserId()).isEqualTo(second.getUserId());
|
||||
assertThat(first.getRoleCode()).isEqualTo("RESIDENT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminLoginUsesStoredAccountBeforeDemoAccounts() {
|
||||
PasswordHasher passwordHasher = new PasswordHasher();
|
||||
AuthService authService = new AuthService(
|
||||
new StubMiniappSessionClient("openid-a"),
|
||||
new StubLoginSessionIssuer(),
|
||||
true,
|
||||
false,
|
||||
new SingleAdminAccountRepository(new AdminAccount(
|
||||
88L,
|
||||
1L,
|
||||
1L,
|
||||
"admin",
|
||||
passwordHasher.hash("stored-secret"),
|
||||
"COMMUNITY_ADMIN",
|
||||
true
|
||||
)),
|
||||
passwordHasher
|
||||
);
|
||||
|
||||
LoginRequest request = new LoginRequest();
|
||||
request.setUsername("admin");
|
||||
request.setPassword("stored-secret");
|
||||
|
||||
LoginResponse response = authService.adminLogin(request);
|
||||
|
||||
assertThat(response.getUserId()).isEqualTo(88L);
|
||||
assertThat(response.getRoleCode()).isEqualTo("COMMUNITY_ADMIN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledStoredAdminAccountCannotLogin() {
|
||||
PasswordHasher passwordHasher = new PasswordHasher();
|
||||
AuthService authService = new AuthService(
|
||||
new StubMiniappSessionClient("openid-a"),
|
||||
new StubLoginSessionIssuer(),
|
||||
true,
|
||||
false,
|
||||
new SingleAdminAccountRepository(new AdminAccount(
|
||||
88L,
|
||||
1L,
|
||||
1L,
|
||||
"admin",
|
||||
passwordHasher.hash("stored-secret"),
|
||||
"COMMUNITY_ADMIN",
|
||||
false
|
||||
)),
|
||||
passwordHasher
|
||||
);
|
||||
|
||||
LoginRequest request = new LoginRequest();
|
||||
request.setUsername("admin");
|
||||
request.setPassword("stored-secret");
|
||||
|
||||
assertThatThrownBy(() -> authService.adminLogin(request))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("disabled");
|
||||
}
|
||||
|
||||
private static class StubMiniappSessionClient implements MiniappSessionClient {
|
||||
private final String openId;
|
||||
|
||||
private StubMiniappSessionClient(String openId) {
|
||||
this.openId = openId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String resolveOpenId(String code) {
|
||||
return openId;
|
||||
}
|
||||
}
|
||||
|
||||
private static class StubLoginSessionIssuer implements LoginSessionIssuer {
|
||||
@Override
|
||||
public LoginResponse issue(Long userId, Long tenantId, Long communityId, String roleCode) {
|
||||
return new LoginResponse("test-token-" + userId, userId, tenantId, communityId, roleCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static class SingleAdminAccountRepository implements AdminAccountRepository {
|
||||
private final AdminAccount account;
|
||||
|
||||
private SingleAdminAccountRepository(AdminAccount account) {
|
||||
this.account = account;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AdminAccount> findByUsername(String username) {
|
||||
if (account.getUsername().equals(username)) {
|
||||
return Optional.of(account);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package com.linhelp.delivery;
|
||||
|
||||
import com.linhelp.common.enums.DeliveryMethod;
|
||||
import com.linhelp.common.enums.DeliveryTaskStatus;
|
||||
import com.linhelp.express.ExpressOrderRequest;
|
||||
import com.linhelp.express.ExpressOrderResponse;
|
||||
import com.linhelp.express.ExpressOrderService;
|
||||
import com.linhelp.groupbuy.GroupBuyOrderRequest;
|
||||
import com.linhelp.groupbuy.GroupBuyOrderResponse;
|
||||
import com.linhelp.groupbuy.GroupBuyOrderService;
|
||||
@@ -37,9 +40,9 @@ class DeliveryAssignmentServiceTests {
|
||||
assertThat(task.getBizType()).isEqualTo(DeliveryAssignmentService.BIZ_TYPE_GOODS_ORDER);
|
||||
assertThat(task.getStatus()).isEqualTo(DeliveryTaskStatus.ASSIGNED);
|
||||
assertThat(task.getRiderId()).isEqualTo(rider.getId());
|
||||
assertThat(task.getContactName()).isEqualTo("李女士");
|
||||
assertThat(task.getContactName()).isEqualTo("Ms Li");
|
||||
assertThat(task.getContactPhone()).isEqualTo("13800001000");
|
||||
assertThat(task.getAddress()).isEqualTo("1号楼 1201 东门旁");
|
||||
assertThat(task.getAddress()).isEqualTo("Building 1 1201 East gate");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -53,10 +56,10 @@ class DeliveryAssignmentServiceTests {
|
||||
assertThat(task.getBizType()).isEqualTo(DeliveryAssignmentService.BIZ_TYPE_GROUP_BUY_ORDER);
|
||||
assertThat(task.getBizOrderNo()).isEqualTo(order.getOrderNo());
|
||||
assertThat(task.getAmountCent()).isEqualTo(order.getAmountCent());
|
||||
assertThat(task.getRemark()).isEqualTo("请下午送达");
|
||||
assertThat(task.getContactName()).isEqualTo("李女士");
|
||||
assertThat(task.getRemark()).isEqualTo("deliver in afternoon");
|
||||
assertThat(task.getContactName()).isEqualTo("Ms Li");
|
||||
assertThat(task.getContactPhone()).isEqualTo("13800001000");
|
||||
assertThat(task.getAddress()).isEqualTo("1号楼 1201 东门旁");
|
||||
assertThat(task.getAddress()).isEqualTo("Building 1 1201 East gate");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -65,8 +68,7 @@ class DeliveryAssignmentServiceTests {
|
||||
GoodsOrderResponse order = fixture.preparedSelfPickupOrder();
|
||||
DeliveryUserResponse rider = fixture.rider();
|
||||
|
||||
assertThatThrownBy(() -> fixture.assignmentService.assignGoodsOrder(order.getId(), rider.getId()))
|
||||
.hasMessageContaining("自提订单无需派单");
|
||||
assertThatThrownBy(() -> fixture.assignmentService.assignGoodsOrder(order.getId(), rider.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -77,8 +79,35 @@ class DeliveryAssignmentServiceTests {
|
||||
|
||||
fixture.assignmentService.assignGoodsOrder(order.getId(), rider.getId());
|
||||
|
||||
assertThatThrownBy(() -> fixture.assignmentService.assignGoodsOrder(order.getId(), rider.getId()))
|
||||
.hasMessageContaining("订单已派单");
|
||||
assertThatThrownBy(() -> fixture.assignmentService.assignGoodsOrder(order.getId(), rider.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminCannotAssignGoodsOrderFromAnotherCommunity() {
|
||||
TestFixture fixture = new TestFixture();
|
||||
GoodsOrderResponse order = fixture.preparedOrderInCommunity(2L);
|
||||
DeliveryUserResponse rider = fixture.rider();
|
||||
|
||||
assertThatThrownBy(() -> fixture.assignmentService.assignGoodsOrder(1L, order.getId(), rider.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminCannotAssignExpressOrderFromAnotherCommunity() {
|
||||
TestFixture fixture = new TestFixture();
|
||||
ExpressOrderResponse order = fixture.pendingPickupExpressOrderInCommunity(2L);
|
||||
DeliveryUserResponse rider = fixture.rider();
|
||||
|
||||
assertThatThrownBy(() -> fixture.assignmentService.assignExpressOrder(1L, order.getId(), rider.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminCannotViewDeliveryTaskFromAnotherCommunity() {
|
||||
TestFixture fixture = new TestFixture();
|
||||
GoodsOrderResponse order = fixture.preparedOrderInCommunity(2L);
|
||||
DeliveryUserResponse rider = fixture.rider(2L);
|
||||
DeliveryOrderResponse task = fixture.assignmentService.assignGoodsOrder(2L, order.getId(), rider.getId());
|
||||
|
||||
assertThatThrownBy(() -> fixture.assignmentService.detailAdmin(1L, task.getId()));
|
||||
}
|
||||
|
||||
private static class TestFixture {
|
||||
@@ -88,8 +117,9 @@ class DeliveryAssignmentServiceTests {
|
||||
private final GroupBuyOrderService groupBuyOrderService = new GroupBuyOrderService(groupBuyService);
|
||||
private final DeliveryUserService deliveryUserService = new DeliveryUserService();
|
||||
private final UserAddressService addressService = new UserAddressService();
|
||||
private final ExpressOrderService expressOrderService = new ExpressOrderService(addressService);
|
||||
private final DeliveryAssignmentService assignmentService =
|
||||
new DeliveryAssignmentService(deliveryUserService, goodsOrderService, null, groupBuyOrderService, addressService);
|
||||
new DeliveryAssignmentService(deliveryUserService, goodsOrderService, expressOrderService, groupBuyOrderService, addressService);
|
||||
|
||||
private GoodsOrderResponse preparedDeliveryOrder() {
|
||||
return preparedOrder(DeliveryMethod.IMMEDIATE, 1L);
|
||||
@@ -99,6 +129,21 @@ class DeliveryAssignmentServiceTests {
|
||||
return preparedOrder(DeliveryMethod.SELF_PICKUP, null);
|
||||
}
|
||||
|
||||
private GoodsOrderResponse preparedOrderInCommunity(Long communityId) {
|
||||
AddressResponse address = address(communityId);
|
||||
ProductSkuResponse sku = productService.createDemoSku(1L, communityId, 1L, "500g", 990, 5);
|
||||
CreateGoodsOrderRequest request = new CreateGoodsOrderRequest();
|
||||
request.setTenantId(1L);
|
||||
request.setCommunityId(communityId);
|
||||
request.setMerchantId(1L);
|
||||
request.setAddressId(address.getId());
|
||||
request.setDeliveryMethod(DeliveryMethod.IMMEDIATE);
|
||||
request.setItems(Collections.singletonList(new CreateGoodsOrderItemRequest(1L, sku.getId(), 1)));
|
||||
GoodsOrderResponse order = goodsOrderService.create(1L, request);
|
||||
goodsOrderService.confirm(order.getId());
|
||||
return goodsOrderService.markPrepared(order.getId());
|
||||
}
|
||||
|
||||
private GoodsOrderResponse preparedOrder(DeliveryMethod deliveryMethod, Long addressId) {
|
||||
Long resolvedAddressId = addressId == null ? null : address().getId();
|
||||
ProductSkuResponse sku = productService.createDemoSku(1L, 1L, 1L, "500g", 990, 5);
|
||||
@@ -118,13 +163,13 @@ class DeliveryAssignmentServiceTests {
|
||||
GroupBuyRequest groupBuyRequest = new GroupBuyRequest();
|
||||
groupBuyRequest.setTenantId(1L);
|
||||
groupBuyRequest.setCommunityId(1L);
|
||||
groupBuyRequest.setTitle("今日鸡蛋团购");
|
||||
groupBuyRequest.setTitle("Today egg group buy");
|
||||
groupBuyRequest.setCoverUrl("https://img.test/egg.jpg");
|
||||
groupBuyRequest.setPriceCent(2990);
|
||||
groupBuyRequest.setStock(100);
|
||||
groupBuyRequest.setStartTime(LocalDateTime.now().minusHours(1));
|
||||
groupBuyRequest.setEndTime(LocalDateTime.now().plusDays(1));
|
||||
groupBuyRequest.setPickupAddress("小区北门");
|
||||
groupBuyRequest.setPickupAddress("North gate");
|
||||
GroupBuyResponse groupBuy = groupBuyService.create(groupBuyRequest);
|
||||
groupBuyService.start(groupBuy.getId());
|
||||
|
||||
@@ -135,7 +180,7 @@ class DeliveryAssignmentServiceTests {
|
||||
orderRequest.setAddressId(address().getId());
|
||||
orderRequest.setQuantity(2);
|
||||
orderRequest.setDeliveryMethod(DeliveryMethod.IMMEDIATE);
|
||||
orderRequest.setRemark("请下午送达");
|
||||
orderRequest.setRemark("deliver in afternoon");
|
||||
|
||||
GroupBuyOrderResponse order = groupBuyOrderService.create(1L, orderRequest);
|
||||
groupBuyOrderService.confirm(order.getId());
|
||||
@@ -143,23 +188,46 @@ class DeliveryAssignmentServiceTests {
|
||||
}
|
||||
|
||||
private AddressResponse address() {
|
||||
return address(1L);
|
||||
}
|
||||
|
||||
private AddressResponse address(Long communityId) {
|
||||
AddressRequest request = new AddressRequest();
|
||||
request.setTenantId(1L);
|
||||
request.setCommunityId(1L);
|
||||
request.setContactName("李女士");
|
||||
request.setCommunityId(communityId);
|
||||
request.setContactName("Ms Li");
|
||||
request.setPhone("13800001000");
|
||||
request.setBuilding("1号楼");
|
||||
request.setBuilding("Building 1");
|
||||
request.setRoom("1201");
|
||||
request.setDetail("东门旁");
|
||||
request.setDetail("East gate");
|
||||
return addressService.create(1L, request);
|
||||
}
|
||||
|
||||
private ExpressOrderResponse pendingPickupExpressOrderInCommunity(Long communityId) {
|
||||
AddressResponse address = address(communityId);
|
||||
ExpressOrderRequest request = new ExpressOrderRequest();
|
||||
request.setTenantId(1L);
|
||||
request.setCommunityId(communityId);
|
||||
request.setAddressId(address.getId());
|
||||
request.setExpressCompany("SF");
|
||||
request.setPickupCode("A-1234");
|
||||
request.setPickupAddress("North gate station");
|
||||
request.setReceiverPhone("13800001000");
|
||||
request.setPackageCount(1);
|
||||
ExpressOrderResponse order = expressOrderService.create(1L, request);
|
||||
return expressOrderService.confirm(order.getId());
|
||||
}
|
||||
|
||||
private DeliveryUserResponse rider() {
|
||||
return rider(1L);
|
||||
}
|
||||
|
||||
private DeliveryUserResponse rider(Long communityId) {
|
||||
DeliveryUserRequest request = new DeliveryUserRequest();
|
||||
request.setTenantId(1L);
|
||||
request.setCommunityId(1L);
|
||||
request.setCommunityId(communityId);
|
||||
request.setUserId(3L);
|
||||
request.setName("骑手一号");
|
||||
request.setName("Rider A");
|
||||
request.setPhone("13900000000");
|
||||
return deliveryUserService.create(request);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
package com.linhelp.express;
|
||||
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.enums.ExpressOrderStatus;
|
||||
import com.linhelp.orderlog.OrderLogRecord;
|
||||
import com.linhelp.orderlog.OrderLogService;
|
||||
import com.linhelp.user.AddressRequest;
|
||||
import com.linhelp.user.AddressResponse;
|
||||
import com.linhelp.user.UserAddressService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@@ -33,32 +40,94 @@ class ExpressOrderServiceTests {
|
||||
AddressResponse otherUserAddress = addressService.create(2L, addressRequest());
|
||||
ExpressOrderService expressOrderService = new ExpressOrderService(addressService);
|
||||
|
||||
assertThatThrownBy(() -> expressOrderService.create(1L, expressRequest(otherUserAddress.getId())))
|
||||
.hasMessageContaining("地址不存在");
|
||||
assertThatThrownBy(() -> expressOrderService.create(1L, expressRequest(otherUserAddress.getId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void feeAdjustmentWritesLog() {
|
||||
void feeAdjustmentWritesDetailLog() {
|
||||
UserAddressService addressService = new UserAddressService();
|
||||
AddressResponse address = addressService.create(1L, addressRequest());
|
||||
ExpressOrderService expressOrderService = new ExpressOrderService(addressService);
|
||||
ExpressOrderResponse order = expressOrderService.create(1L, expressRequest(address.getId()));
|
||||
|
||||
expressOrderService.adjustFee(order.getId(), 800, "大件加价");
|
||||
expressOrderService.adjustFee(order.getId(), 800, "large package");
|
||||
|
||||
ExpressOrderResponse detail = expressOrderService.detail(order.getId());
|
||||
assertThat(detail.getFeeCent()).isEqualTo(800);
|
||||
assertThat(detail.getLogs()).extracting(ExpressOrderLogResponse::getContent)
|
||||
.anyMatch(content -> content.contains("大件加价"));
|
||||
.anyMatch(content -> content.contains("large package"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userCannotViewCancelOrCompleteAnotherUsersExpressOrder() {
|
||||
UserAddressService addressService = new UserAddressService();
|
||||
AddressResponse address = addressService.create(1L, addressRequest());
|
||||
ExpressOrderService expressOrderService = new ExpressOrderService(addressService);
|
||||
ExpressOrderResponse order = expressOrderService.create(1L, expressRequest(address.getId()));
|
||||
|
||||
assertThatThrownBy(() -> expressOrderService.detailMine(2L, order.getId()));
|
||||
assertThatThrownBy(() -> expressOrderService.cancelMine(2L, order.getId(), "cancel"));
|
||||
assertThatThrownBy(() -> expressOrderService.completeMine(2L, order.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminCannotConfirmExpressOrderFromAnotherCommunity() {
|
||||
UserAddressService addressService = new UserAddressService();
|
||||
AddressRequest addressRequest = addressRequest();
|
||||
addressRequest.setCommunityId(2L);
|
||||
AddressResponse address = addressService.create(1L, addressRequest);
|
||||
ExpressOrderService expressOrderService = new ExpressOrderService(addressService);
|
||||
ExpressOrderRequest request = expressRequest(address.getId());
|
||||
request.setCommunityId(2L);
|
||||
ExpressOrderResponse order = expressOrderService.create(1L, request);
|
||||
|
||||
assertThatThrownBy(() -> expressOrderService.confirmAdmin(1L, order.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminExpressOrderPageFiltersAndPaginates() {
|
||||
UserAddressService addressService = new UserAddressService();
|
||||
AddressResponse address = addressService.create(1L, addressRequest());
|
||||
ExpressOrderService expressOrderService = new ExpressOrderService(addressService);
|
||||
ExpressOrderResponse first = expressOrderService.create(1L, expressRequest(address.getId()));
|
||||
ExpressOrderResponse second = expressOrderService.create(1L, expressRequest(address.getId()));
|
||||
expressOrderService.create(1L, expressRequest(address.getId()));
|
||||
|
||||
expressOrderService.confirm(first.getId());
|
||||
|
||||
PageResponse<ExpressOrderResponse> page = expressOrderService.listAdminPage(1L, ExpressOrderStatus.PENDING_CONFIRM, 1, 1);
|
||||
|
||||
assertThat(page.getTotal()).isEqualTo(2);
|
||||
assertThat(page.getRecords()).extracting(ExpressOrderResponse::getId).containsExactly(second.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressOrderWritesUnifiedLogsAndPersistsSnapshots() {
|
||||
UserAddressService addressService = new UserAddressService();
|
||||
AddressResponse address = addressService.create(1L, addressRequest());
|
||||
OrderLogService orderLogService = new OrderLogService();
|
||||
RecordingExpressOrderStore store = new RecordingExpressOrderStore();
|
||||
ExpressOrderService expressOrderService = new ExpressOrderService(addressService, orderLogService, store);
|
||||
|
||||
ExpressOrderResponse order = expressOrderService.create(1L, expressRequest(address.getId()));
|
||||
expressOrderService.confirmAdmin(1L, order.getId(), 9L);
|
||||
|
||||
List<OrderLogRecord> logs = orderLogService.list("EXPRESS_ORDER", order.getId());
|
||||
assertThat(logs).extracting(OrderLogRecord::getAction).containsExactly("CREATE", "CONFIRM");
|
||||
assertThat(logs.get(1).getFromStatus()).isEqualTo("PENDING_CONFIRM");
|
||||
assertThat(logs.get(1).getToStatus()).isEqualTo("PENDING_PICKUP");
|
||||
assertThat(logs.get(1).getOperatorId()).isEqualTo(9L);
|
||||
assertThat(store.saved).extracting(ExpressOrderResponse::getStatus)
|
||||
.containsExactly(ExpressOrderStatus.PENDING_CONFIRM, ExpressOrderStatus.PENDING_PICKUP);
|
||||
}
|
||||
|
||||
private ExpressOrderRequest expressRequest(Long addressId) {
|
||||
ExpressOrderRequest request = new ExpressOrderRequest();
|
||||
request.setTenantId(1L);
|
||||
request.setCommunityId(1L);
|
||||
request.setExpressCompany("顺丰");
|
||||
request.setExpressCompany("SF");
|
||||
request.setPickupCode("A-1234");
|
||||
request.setPickupAddress("北门快递站");
|
||||
request.setPickupAddress("North gate");
|
||||
request.setReceiverPhone("13800000000");
|
||||
request.setAddressId(addressId);
|
||||
request.setPackageCount(1);
|
||||
@@ -69,11 +138,28 @@ class ExpressOrderServiceTests {
|
||||
AddressRequest request = new AddressRequest();
|
||||
request.setTenantId(1L);
|
||||
request.setCommunityId(1L);
|
||||
request.setContactName("张三");
|
||||
request.setContactName("Zhang San");
|
||||
request.setPhone("13800000000");
|
||||
request.setBuilding("1栋");
|
||||
request.setBuilding("1");
|
||||
request.setRoom("101");
|
||||
request.setDetail("门口");
|
||||
request.setDetail("Door");
|
||||
return request;
|
||||
}
|
||||
|
||||
private static class RecordingExpressOrderStore implements ExpressOrderStore {
|
||||
private final List<ExpressOrderResponse> saved = new ArrayList<ExpressOrderResponse>();
|
||||
|
||||
@Override
|
||||
public List<ExpressOrderResponse> loadAll() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(ExpressOrderResponse order) {
|
||||
ExpressOrderResponse snapshot = new ExpressOrderResponse();
|
||||
snapshot.setId(order.getId());
|
||||
snapshot.setStatus(order.getStatus());
|
||||
saved.add(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package com.linhelp.groupbuy;
|
||||
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.enums.DeliveryMethod;
|
||||
import com.linhelp.common.enums.GroupBuyOrderStatus;
|
||||
import com.linhelp.orderlog.OrderLogRecord;
|
||||
import com.linhelp.orderlog.OrderLogService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -17,8 +23,7 @@ class GroupBuyServiceTests {
|
||||
GroupBuyResponse groupBuy = groupBuyService.create(groupBuyRequest(LocalDateTime.now().minusMinutes(1)));
|
||||
groupBuyService.start(groupBuy.getId());
|
||||
|
||||
assertThatThrownBy(() -> orderService.create(1L, orderRequest(groupBuy.getId(), DeliveryMethod.IMMEDIATE)))
|
||||
.hasMessageContaining("团购已截单");
|
||||
assertThatThrownBy(() -> orderService.create(1L, orderRequest(groupBuy.getId(), DeliveryMethod.IMMEDIATE)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -35,17 +40,90 @@ class GroupBuyServiceTests {
|
||||
assertThat(orderService.detail(order.getId()).getStatus()).isEqualTo(GroupBuyOrderStatus.PENDING_PICKUP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void userCannotViewCancelOrCompleteAnotherUsersGroupBuyOrder() {
|
||||
GroupBuyService groupBuyService = new GroupBuyService();
|
||||
GroupBuyOrderService orderService = new GroupBuyOrderService(groupBuyService);
|
||||
GroupBuyResponse groupBuy = groupBuyService.create(groupBuyRequest(LocalDateTime.now().plusDays(1)));
|
||||
groupBuyService.start(groupBuy.getId());
|
||||
GroupBuyOrderResponse order = orderService.create(1L, orderRequest(groupBuy.getId(), DeliveryMethod.IMMEDIATE));
|
||||
|
||||
assertThatThrownBy(() -> orderService.detailMine(2L, order.getId()));
|
||||
assertThatThrownBy(() -> orderService.cancelMine(2L, order.getId()));
|
||||
assertThatThrownBy(() -> orderService.completeMine(2L, order.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminCannotConfirmGroupBuyOrderFromAnotherCommunity() {
|
||||
GroupBuyService groupBuyService = new GroupBuyService();
|
||||
GroupBuyOrderService orderService = new GroupBuyOrderService(groupBuyService);
|
||||
GroupBuyRequest groupBuyRequest = groupBuyRequest(LocalDateTime.now().plusDays(1));
|
||||
groupBuyRequest.setCommunityId(2L);
|
||||
GroupBuyResponse groupBuy = groupBuyService.create(groupBuyRequest);
|
||||
groupBuyService.start(groupBuy.getId());
|
||||
GroupBuyOrderRequest orderRequest = orderRequest(groupBuy.getId(), DeliveryMethod.IMMEDIATE);
|
||||
orderRequest.setCommunityId(2L);
|
||||
GroupBuyOrderResponse order = orderService.create(1L, orderRequest);
|
||||
|
||||
assertThatThrownBy(() -> orderService.confirmAdmin(1L, order.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void groupBuyOrderPageFiltersAndPaginates() {
|
||||
GroupBuyService groupBuyService = new GroupBuyService();
|
||||
GroupBuyOrderService orderService = new GroupBuyOrderService(groupBuyService);
|
||||
GroupBuyResponse groupBuy = groupBuyService.create(groupBuyRequest(LocalDateTime.now().plusDays(1)));
|
||||
groupBuyService.start(groupBuy.getId());
|
||||
GroupBuyOrderResponse first = orderService.create(1L, orderRequest(groupBuy.getId(), DeliveryMethod.IMMEDIATE));
|
||||
GroupBuyOrderResponse second = orderService.create(1L, orderRequest(groupBuy.getId(), DeliveryMethod.IMMEDIATE));
|
||||
orderService.create(1L, orderRequest(groupBuy.getId(), DeliveryMethod.IMMEDIATE));
|
||||
|
||||
orderService.confirm(first.getId());
|
||||
|
||||
PageResponse<GroupBuyOrderResponse> page = orderService.listByGroupBuyAdminPage(
|
||||
1L,
|
||||
groupBuy.getId(),
|
||||
GroupBuyOrderStatus.PENDING_CONFIRM,
|
||||
1,
|
||||
1
|
||||
);
|
||||
|
||||
assertThat(page.getTotal()).isEqualTo(2);
|
||||
assertThat(page.getRecords()).extracting(GroupBuyOrderResponse::getId).containsExactly(second.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void groupBuyOrderWritesLogsAndPersistsSnapshots() {
|
||||
GroupBuyService groupBuyService = new GroupBuyService();
|
||||
OrderLogService orderLogService = new OrderLogService();
|
||||
RecordingGroupBuyOrderStore store = new RecordingGroupBuyOrderStore();
|
||||
GroupBuyOrderService orderService = new GroupBuyOrderService(groupBuyService, orderLogService, store);
|
||||
GroupBuyResponse groupBuy = groupBuyService.create(groupBuyRequest(LocalDateTime.now().plusDays(1)));
|
||||
groupBuyService.start(groupBuy.getId());
|
||||
|
||||
GroupBuyOrderResponse order = orderService.create(1L, orderRequest(groupBuy.getId(), DeliveryMethod.IMMEDIATE));
|
||||
orderService.confirmAdmin(1L, order.getId(), 9L);
|
||||
|
||||
List<OrderLogRecord> logs = orderLogService.list("GROUP_BUY_ORDER", order.getId());
|
||||
assertThat(logs).extracting(OrderLogRecord::getAction).containsExactly("CREATE", "CONFIRM");
|
||||
assertThat(logs.get(1).getFromStatus()).isEqualTo("PENDING_CONFIRM");
|
||||
assertThat(logs.get(1).getToStatus()).isEqualTo("CONFIRMED");
|
||||
assertThat(logs.get(1).getOperatorId()).isEqualTo(9L);
|
||||
assertThat(store.saved).extracting(GroupBuyOrderResponse::getStatus)
|
||||
.containsExactly(GroupBuyOrderStatus.PENDING_CONFIRM, GroupBuyOrderStatus.CONFIRMED);
|
||||
}
|
||||
|
||||
private GroupBuyRequest groupBuyRequest(LocalDateTime endTime) {
|
||||
GroupBuyRequest request = new GroupBuyRequest();
|
||||
request.setTenantId(1L);
|
||||
request.setCommunityId(1L);
|
||||
request.setTitle("今日鸡蛋团购");
|
||||
request.setTitle("Today egg group buy");
|
||||
request.setCoverUrl("https://img.test/egg.jpg");
|
||||
request.setPriceCent(2990);
|
||||
request.setStock(100);
|
||||
request.setStartTime(LocalDateTime.now().minusHours(1));
|
||||
request.setEndTime(endTime);
|
||||
request.setPickupAddress("小区北门");
|
||||
request.setPickupAddress("North gate");
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -59,4 +137,21 @@ class GroupBuyServiceTests {
|
||||
request.setDeliveryMethod(deliveryMethod);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static class RecordingGroupBuyOrderStore implements GroupBuyOrderStore {
|
||||
private final List<GroupBuyOrderResponse> saved = new ArrayList<GroupBuyOrderResponse>();
|
||||
|
||||
@Override
|
||||
public List<GroupBuyOrderResponse> loadAll() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(GroupBuyOrderResponse order) {
|
||||
GroupBuyOrderResponse snapshot = new GroupBuyOrderResponse();
|
||||
snapshot.setId(order.getId());
|
||||
snapshot.setStatus(order.getStatus());
|
||||
saved.add(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package com.linhelp.order;
|
||||
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.enums.DeliveryMethod;
|
||||
import com.linhelp.common.enums.GoodsOrderStatus;
|
||||
import com.linhelp.orderlog.OrderLogRecord;
|
||||
import com.linhelp.orderlog.OrderLogService;
|
||||
import com.linhelp.product.ProductService;
|
||||
import com.linhelp.product.ProductSkuResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -27,8 +32,7 @@ class GoodsOrderServiceTests {
|
||||
request.setDeliveryMethod(DeliveryMethod.IMMEDIATE);
|
||||
request.setItems(Collections.singletonList(new CreateGoodsOrderItemRequest(1L, sku.getId(), 99)));
|
||||
|
||||
assertThatThrownBy(() -> goodsOrderService.create(1L, request))
|
||||
.hasMessageContaining("库存不足");
|
||||
assertThatThrownBy(() -> goodsOrderService.create(1L, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -50,4 +54,92 @@ class GoodsOrderServiceTests {
|
||||
|
||||
assertThat(goodsOrderService.detail(order.getId()).getStatus()).isEqualTo(GoodsOrderStatus.PENDING_PICKUP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void userCannotViewOrCancelAnotherUsersGoodsOrder() {
|
||||
ProductService productService = new ProductService();
|
||||
ProductSkuResponse sku = productService.createDemoSku(1L, 1L, 1L, "500g", 990, 5);
|
||||
GoodsOrderService goodsOrderService = new GoodsOrderService(productService);
|
||||
GoodsOrderResponse order = goodsOrderService.create(1L, deliveryRequest(sku.getId(), 1L));
|
||||
|
||||
assertThatThrownBy(() -> goodsOrderService.detailMine(2L, order.getId()));
|
||||
assertThatThrownBy(() -> goodsOrderService.cancelMine(2L, order.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminCannotConfirmGoodsOrderFromAnotherCommunity() {
|
||||
ProductService productService = new ProductService();
|
||||
ProductSkuResponse sku = productService.createDemoSku(1L, 2L, 1L, "500g", 990, 5);
|
||||
GoodsOrderService goodsOrderService = new GoodsOrderService(productService);
|
||||
GoodsOrderResponse order = goodsOrderService.create(1L, deliveryRequest(sku.getId(), 2L));
|
||||
|
||||
assertThatThrownBy(() -> goodsOrderService.confirmAdmin(1L, order.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminGoodsOrderPageFiltersAndPaginates() {
|
||||
ProductService productService = new ProductService();
|
||||
ProductSkuResponse sku = productService.createDemoSku(1L, 1L, 1L, "500g", 990, 10);
|
||||
GoodsOrderService goodsOrderService = new GoodsOrderService(productService);
|
||||
GoodsOrderResponse first = goodsOrderService.create(1L, deliveryRequest(sku.getId(), 1L));
|
||||
GoodsOrderResponse second = goodsOrderService.create(1L, deliveryRequest(sku.getId(), 1L));
|
||||
goodsOrderService.create(1L, deliveryRequest(sku.getId(), 1L));
|
||||
|
||||
goodsOrderService.confirm(first.getId());
|
||||
|
||||
PageResponse<GoodsOrderResponse> page = goodsOrderService.listAdminPage(1L, GoodsOrderStatus.PENDING_CONFIRM, 1, 1);
|
||||
|
||||
assertThat(page.getTotal()).isEqualTo(2);
|
||||
assertThat(page.getPageNo()).isEqualTo(1);
|
||||
assertThat(page.getPageSize()).isEqualTo(1);
|
||||
assertThat(page.getRecords()).extracting(GoodsOrderResponse::getId).containsExactly(second.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void goodsOrderWritesLogsAndPersistsSnapshots() {
|
||||
ProductService productService = new ProductService();
|
||||
ProductSkuResponse sku = productService.createDemoSku(1L, 1L, 1L, "500g", 990, 5);
|
||||
OrderLogService orderLogService = new OrderLogService();
|
||||
RecordingGoodsOrderStore store = new RecordingGoodsOrderStore();
|
||||
GoodsOrderService goodsOrderService = new GoodsOrderService(productService, orderLogService, store);
|
||||
|
||||
GoodsOrderResponse order = goodsOrderService.create(1L, deliveryRequest(sku.getId(), 1L));
|
||||
goodsOrderService.confirmAdmin(1L, order.getId(), 9L);
|
||||
|
||||
List<OrderLogRecord> logs = orderLogService.list("GOODS_ORDER", order.getId());
|
||||
assertThat(logs).extracting(OrderLogRecord::getAction).containsExactly("CREATE", "CONFIRM");
|
||||
assertThat(logs.get(1).getFromStatus()).isEqualTo("PENDING_CONFIRM");
|
||||
assertThat(logs.get(1).getToStatus()).isEqualTo("PREPARING");
|
||||
assertThat(logs.get(1).getOperatorId()).isEqualTo(9L);
|
||||
assertThat(store.saved).extracting(GoodsOrderResponse::getStatus)
|
||||
.containsExactly(GoodsOrderStatus.PENDING_CONFIRM, GoodsOrderStatus.PREPARING);
|
||||
}
|
||||
|
||||
private CreateGoodsOrderRequest deliveryRequest(Long skuId, Long communityId) {
|
||||
CreateGoodsOrderRequest request = new CreateGoodsOrderRequest();
|
||||
request.setTenantId(1L);
|
||||
request.setCommunityId(communityId);
|
||||
request.setMerchantId(1L);
|
||||
request.setAddressId(1L);
|
||||
request.setDeliveryMethod(DeliveryMethod.IMMEDIATE);
|
||||
request.setItems(Collections.singletonList(new CreateGoodsOrderItemRequest(1L, skuId, 1)));
|
||||
return request;
|
||||
}
|
||||
|
||||
private static class RecordingGoodsOrderStore implements GoodsOrderStore {
|
||||
private final List<GoodsOrderResponse> saved = new ArrayList<GoodsOrderResponse>();
|
||||
|
||||
@Override
|
||||
public List<GoodsOrderResponse> loadAll() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(GoodsOrderResponse order) {
|
||||
GoodsOrderResponse snapshot = new GoodsOrderResponse();
|
||||
snapshot.setId(order.getId());
|
||||
snapshot.setStatus(order.getStatus());
|
||||
saved.add(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.linhelp.product;
|
||||
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -15,4 +16,41 @@ class ProductInventoryTests {
|
||||
|
||||
assertThat(productService.getSku(sku.getId()).getStock()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void miniProductPageFiltersByKeywordAndPaginates() {
|
||||
ProductService productService = new ProductService();
|
||||
ProductCategoryRequest categoryRequest = new ProductCategoryRequest();
|
||||
categoryRequest.setTenantId(1L);
|
||||
categoryRequest.setCommunityId(1L);
|
||||
categoryRequest.setName("水果");
|
||||
categoryRequest.setSortNo(1);
|
||||
categoryRequest.setEnabled(true);
|
||||
ProductCategoryResponse category = productService.createCategory(categoryRequest);
|
||||
|
||||
createProduct(productService, category.getId(), "红富士苹果");
|
||||
createProduct(productService, category.getId(), "香蕉");
|
||||
createProduct(productService, category.getId(), "苹果汁");
|
||||
|
||||
PageResponse<ProductResponse> page = productService.listMiniPage(1L, null, "苹果", 1, 1);
|
||||
|
||||
assertThat(page.getTotal()).isEqualTo(2);
|
||||
assertThat(page.getPageNo()).isEqualTo(1);
|
||||
assertThat(page.getPageSize()).isEqualTo(1);
|
||||
assertThat(page.getRecords()).hasSize(1);
|
||||
assertThat(page.getRecords().get(0).getName()).contains("苹果");
|
||||
}
|
||||
|
||||
private ProductResponse createProduct(ProductService productService, Long categoryId, String name) {
|
||||
ProductRequest request = new ProductRequest();
|
||||
request.setTenantId(1L);
|
||||
request.setCommunityId(1L);
|
||||
request.setMerchantId(1L);
|
||||
request.setCategoryId(categoryId);
|
||||
request.setName(name);
|
||||
request.setUnitName("份");
|
||||
ProductResponse product = productService.createProduct(request);
|
||||
productService.onShelf(product.getId());
|
||||
return product;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.linhelp.secondhand;
|
||||
|
||||
import com.linhelp.common.api.PageResponse;
|
||||
import com.linhelp.common.enums.SecondGoodsStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -32,14 +33,36 @@ class SecondGoodsReviewTests {
|
||||
.hasMessageContaining("拒绝原因不能为空");
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicPageFiltersByKeywordAndCategory() {
|
||||
SecondGoodsService service = new SecondGoodsService();
|
||||
SecondGoodsResponse bike = service.create(1L, request("九成新儿童车", "母婴"));
|
||||
SecondGoodsResponse chair = service.create(1L, request("儿童餐椅", "母婴"));
|
||||
SecondGoodsResponse phone = service.create(1L, request("备用手机", "数码"));
|
||||
service.approve(bike.getId());
|
||||
service.approve(chair.getId());
|
||||
service.approve(phone.getId());
|
||||
|
||||
PageResponse<SecondGoodsResponse> page = service.listPublicPage(1L, "母婴", "儿童", 1, 1);
|
||||
|
||||
assertThat(page.getTotal()).isEqualTo(2);
|
||||
assertThat(page.getRecords()).hasSize(1);
|
||||
assertThat(page.getRecords().get(0).getCategory()).isEqualTo("母婴");
|
||||
assertThat(page.getRecords().get(0).getTitle()).contains("儿童");
|
||||
}
|
||||
|
||||
private SecondGoodsRequest request() {
|
||||
return request("九成新儿童车", "母婴");
|
||||
}
|
||||
|
||||
private SecondGoodsRequest request(String title, String category) {
|
||||
SecondGoodsRequest request = new SecondGoodsRequest();
|
||||
request.setTenantId(1L);
|
||||
request.setCommunityId(1L);
|
||||
request.setTitle("九成新儿童车");
|
||||
request.setTitle(title);
|
||||
request.setPriceCent(9900);
|
||||
request.setDescription("小区内自提");
|
||||
request.setCategory("母婴");
|
||||
request.setCategory(category);
|
||||
request.setContactPhone("13800000000");
|
||||
request.setTradeMethod("FACE_TO_FACE");
|
||||
request.setImageUrls(Arrays.asList("https://img.test/bike.jpg"));
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
package com.linhelp.storage;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class FileStorageServiceTests {
|
||||
@TempDir
|
||||
Path uploadRoot;
|
||||
|
||||
@Test
|
||||
void imageUploadUsesTenantCommunityDatePrefix() {
|
||||
FileStorageService service = new FileStorageService();
|
||||
void imageUploadWritesFileAndUsesTenantCommunityDatePrefix() {
|
||||
FileStorageService service = new FileStorageService(uploadRoot, "/uploads", 1024L);
|
||||
MockMultipartFile file = new MockMultipartFile("file", "avatar.png", "image/png", new byte[]{1, 2, 3});
|
||||
|
||||
FileUploadResponse response = service.upload(1L, 2L, file);
|
||||
@@ -17,14 +24,24 @@ class FileStorageServiceTests {
|
||||
assertThat(response.getObjectKey()).startsWith("tenant/1/community/2/");
|
||||
assertThat(response.getObjectKey()).endsWith(".png");
|
||||
assertThat(response.getUrl()).contains(response.getObjectKey());
|
||||
assertThat(Files.exists(uploadRoot.resolve(response.getObjectKey()))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonImageUploadIsRejected() {
|
||||
FileStorageService service = new FileStorageService();
|
||||
FileStorageService service = new FileStorageService(uploadRoot, "/uploads", 1024L);
|
||||
MockMultipartFile file = new MockMultipartFile("file", "doc.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
assertThatThrownBy(() -> service.upload(1L, 1L, file))
|
||||
.hasMessageContaining("仅支持图片上传");
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedUploadIsRejected() {
|
||||
FileStorageService service = new FileStorageService(uploadRoot, "/uploads", 2L);
|
||||
MockMultipartFile file = new MockMultipartFile("file", "avatar.png", "image/png", new byte[]{1, 2, 3});
|
||||
|
||||
assertThatThrownBy(() -> service.upload(1L, 1L, file))
|
||||
.hasMessageContaining("文件大小不能超过");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,21 @@ function listCategories() {
|
||||
return request({ url: '/api/mini/product-categories' })
|
||||
}
|
||||
|
||||
function listProducts(categoryId) {
|
||||
function listProducts(categoryId, keyword) {
|
||||
return request({
|
||||
url: '/api/mini/products',
|
||||
data: categoryId ? { categoryId } : {}
|
||||
})
|
||||
url: '/api/mini/products/page',
|
||||
data: buildProductQuery(categoryId, keyword)
|
||||
}).then((page) => page && page.records ? page.records : [])
|
||||
}
|
||||
|
||||
function buildProductQuery(categoryId, keyword) {
|
||||
const data = {
|
||||
pageNo: 1,
|
||||
pageSize: 20
|
||||
}
|
||||
if (categoryId) data.categoryId = categoryId
|
||||
if (keyword) data.keyword = keyword
|
||||
return data
|
||||
}
|
||||
|
||||
function getProductDetail(id) {
|
||||
@@ -26,6 +36,7 @@ function createGoodsOrder(data) {
|
||||
module.exports = {
|
||||
listCategories,
|
||||
listProducts,
|
||||
buildProductQuery,
|
||||
getProductDetail,
|
||||
createGoodsOrder
|
||||
}
|
||||
|
||||
12
miniapp/miniprogram/api/productApi.test.js
Normal file
12
miniapp/miniprogram/api/productApi.test.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const { buildProductQuery } = require('./productApi')
|
||||
|
||||
describe('buildProductQuery', () => {
|
||||
test('keeps pagination and optional filters stable', () => {
|
||||
expect(buildProductQuery(2, '苹果')).toEqual({
|
||||
pageNo: 1,
|
||||
pageSize: 20,
|
||||
categoryId: 2,
|
||||
keyword: '苹果'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,20 @@
|
||||
const { request } = require('../utils/request')
|
||||
|
||||
function listSecondGoods(category) {
|
||||
function listSecondGoods(category, keyword) {
|
||||
return request({
|
||||
url: '/api/mini/second-goods',
|
||||
data: category ? { category } : {}
|
||||
})
|
||||
url: '/api/mini/second-goods/page',
|
||||
data: buildSecondGoodsQuery(category, keyword)
|
||||
}).then((page) => page && page.records ? page.records : [])
|
||||
}
|
||||
|
||||
function buildSecondGoodsQuery(category, keyword) {
|
||||
const data = {
|
||||
pageNo: 1,
|
||||
pageSize: 20
|
||||
}
|
||||
if (category) data.category = category
|
||||
if (keyword) data.keyword = keyword
|
||||
return data
|
||||
}
|
||||
|
||||
function getSecondGoodsDetail(id) {
|
||||
@@ -21,6 +31,7 @@ function createSecondGoods(data) {
|
||||
|
||||
module.exports = {
|
||||
listSecondGoods,
|
||||
buildSecondGoodsQuery,
|
||||
getSecondGoodsDetail,
|
||||
createSecondGoods
|
||||
}
|
||||
|
||||
12
miniapp/miniprogram/api/secondGoodsApi.test.js
Normal file
12
miniapp/miniprogram/api/secondGoodsApi.test.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const { buildSecondGoodsQuery } = require('./secondGoodsApi')
|
||||
|
||||
describe('buildSecondGoodsQuery', () => {
|
||||
test('keeps pagination and optional filters stable', () => {
|
||||
expect(buildSecondGoodsQuery('母婴', '儿童')).toEqual({
|
||||
pageNo: 1,
|
||||
pageSize: 20,
|
||||
category: '母婴',
|
||||
keyword: '儿童'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
App({
|
||||
globalData: {
|
||||
apiBaseUrl: 'http://localhost:8080',
|
||||
useDevLogin: true,
|
||||
token: '',
|
||||
user: null
|
||||
}
|
||||
|
||||
@@ -1,31 +1,87 @@
|
||||
page {
|
||||
background: #f4f6f8;
|
||||
color: #17212b;
|
||||
background: #f3f6f8;
|
||||
color: #16212f;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
view,
|
||||
text,
|
||||
input,
|
||||
textarea,
|
||||
button {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 24rpx;
|
||||
padding: 28rpx 24rpx 48rpx;
|
||||
}
|
||||
|
||||
.section {
|
||||
.page-heading {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.heading-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 40rpx;
|
||||
padding: 0 14rpx;
|
||||
color: #0f766e;
|
||||
background: #e6f4f1;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.heading-title {
|
||||
margin-top: 10rpx;
|
||||
color: #111827;
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.heading-subtitle {
|
||||
margin-top: 8rpx;
|
||||
color: #64748b;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 16rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #e5e9ef;
|
||||
border-radius: 8rpx;
|
||||
padding: 20rpx;
|
||||
border: 1rpx solid #e4eaf0;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: #111827;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.row {
|
||||
@@ -38,6 +94,12 @@ page {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 24rpx;
|
||||
@@ -49,12 +111,29 @@ page {
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 4rpx 12rpx;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36rpx;
|
||||
padding: 4rpx 14rpx;
|
||||
background: #e6f4f1;
|
||||
color: #0f766e;
|
||||
border-radius: 6rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 44rpx;
|
||||
padding: 0 16rpx;
|
||||
color: #33536f;
|
||||
background: #edf4f8;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.grid {
|
||||
@@ -72,7 +151,7 @@ page {
|
||||
justify-content: center;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #e5e9ef;
|
||||
border-radius: 8rpx;
|
||||
border-radius: 16rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -80,17 +159,41 @@ page {
|
||||
.textarea,
|
||||
.picker {
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
padding: 0 18rpx;
|
||||
min-height: 84rpx;
|
||||
padding: 0 22rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #d9e0e8;
|
||||
border-radius: 6rpx;
|
||||
margin-bottom: 16rpx;
|
||||
border: 1rpx solid #d9e2ea;
|
||||
border-radius: 14rpx;
|
||||
margin-bottom: 20rpx;
|
||||
color: #17212b;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 140rpx;
|
||||
padding-top: 16rpx;
|
||||
padding-top: 18rpx;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
margin: 6rpx 0 12rpx;
|
||||
color: #334155;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #e4eaf0;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
@@ -99,12 +202,19 @@ page {
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 6rpx;
|
||||
border-radius: 14rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #0f766e;
|
||||
color: #ffffff;
|
||||
border: 1rpx solid #0f766e;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
@@ -112,3 +222,38 @@ button {
|
||||
color: #0f766e;
|
||||
border: 1rpx solid #0f766e;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
min-height: 88rpx;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
min-height: 180rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
background: #ffffff;
|
||||
border: 1rpx dashed #cbd5e1;
|
||||
border-radius: 16rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.media-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #0f766e;
|
||||
background: #e6f4f1;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1rpx;
|
||||
margin: 20rpx 0;
|
||||
background: #edf1f5;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
<view class="page">
|
||||
<view class="section-title">地址管理</view>
|
||||
<view class="card">
|
||||
<input class="input" placeholder="联系人" data-field="contactName" value="{{form.contactName}}" bindinput="onFieldInput" />
|
||||
<input class="input" placeholder="手机号" data-field="phone" value="{{form.phone}}" bindinput="onFieldInput" />
|
||||
<input class="input" placeholder="楼栋" data-field="building" value="{{form.building}}" bindinput="onFieldInput" />
|
||||
<input class="input" placeholder="房号" data-field="room" value="{{form.room}}" bindinput="onFieldInput" />
|
||||
<input class="input" placeholder="详细地址" data-field="detail" value="{{form.detail}}" bindinput="onFieldInput" />
|
||||
<button class="primary" loading="{{submitting}}" bindtap="submit">保存地址</button>
|
||||
<view class="page-heading">
|
||||
<view class="heading-eyebrow">配送资料</view>
|
||||
<view class="heading-title">地址管理</view>
|
||||
<view class="heading-subtitle">保存常用住址,提交订单更快</view>
|
||||
</view>
|
||||
<view wx:for="{{addresses}}" wx:key="id" class="card">
|
||||
<view class="row between">
|
||||
<view>{{item.contactName}} {{item.phone}}</view>
|
||||
|
||||
<view class="address-form form-card">
|
||||
<view class="card-title form-title">新增地址</view>
|
||||
<view class="field-label">联系人</view>
|
||||
<input class="input" placeholder="联系人" data-field="contactName" value="{{form.contactName}}" bindinput="onFieldInput" />
|
||||
|
||||
<view class="field-label">手机号</view>
|
||||
<input class="input" placeholder="手机号" data-field="phone" value="{{form.phone}}" bindinput="onFieldInput" />
|
||||
|
||||
<view class="field-label">楼栋</view>
|
||||
<input class="input" placeholder="楼栋" data-field="building" value="{{form.building}}" bindinput="onFieldInput" />
|
||||
|
||||
<view class="field-label">房号</view>
|
||||
<input class="input" placeholder="房号" data-field="room" value="{{form.room}}" bindinput="onFieldInput" />
|
||||
|
||||
<view class="field-label">详细地址</view>
|
||||
<input class="input" placeholder="详细地址" data-field="detail" value="{{form.detail}}" bindinput="onFieldInput" />
|
||||
<button class="primary action-button" loading="{{submitting}}" bindtap="submit">保存地址</button>
|
||||
</view>
|
||||
|
||||
<view class="section-header address-header">
|
||||
<view class="section-title">已保存地址</view>
|
||||
</view>
|
||||
|
||||
<view wx:for="{{addresses}}" wx:key="id" class="address-card">
|
||||
<view class="row between address-top">
|
||||
<view class="card-title">{{item.contactName}} {{item.phone}}</view>
|
||||
<view wx:if="{{item.default}}" class="tag">默认</view>
|
||||
</view>
|
||||
<view class="muted">{{item.building}} {{item.room}} {{item.detail}}</view>
|
||||
<button wx:if="{{!item.default}}" size="mini" class="ghost" data-id="{{item.id}}" bindtap="setDefault">设为默认</button>
|
||||
<view class="address-detail">{{item.building}} {{item.room}} {{item.detail}}</view>
|
||||
<button wx:if="{{!item.default}}" size="mini" class="ghost default-button" data-id="{{item.id}}" bindtap="setDefault">设为默认</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!addresses.length}}" class="empty-state">暂无地址</view>
|
||||
</view>
|
||||
|
||||
@@ -1,3 +1,41 @@
|
||||
.ghost {
|
||||
margin-top: 16rpx;
|
||||
.page-heading {
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.address-form {
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.address-header {
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.address-card {
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #e4eaf0;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
|
||||
}
|
||||
|
||||
.address-top {
|
||||
align-items: flex-start;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.address-detail {
|
||||
margin-top: 12rpx;
|
||||
color: #475569;
|
||||
font-size: 26rpx;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.default-button {
|
||||
margin: 18rpx 0 0;
|
||||
min-width: 154rpx;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
<view class="page">
|
||||
<view class="section-title">代取快递</view>
|
||||
<view class="card">
|
||||
<view class="page-heading">
|
||||
<view class="heading-eyebrow">快递服务</view>
|
||||
<view class="heading-title">代取快递</view>
|
||||
<view class="heading-subtitle">填写取件信息,配送员帮你送到家</view>
|
||||
</view>
|
||||
|
||||
<view class="express-tips">
|
||||
<view class="tips-title">收费规则</view>
|
||||
<view class="muted">首件 3 元,第二件起每件加 1 元,异常订单后台可调整。</view>
|
||||
</view>
|
||||
|
||||
<view class="form-card express-form">
|
||||
<view class="field-label">快递公司</view>
|
||||
<input class="input" placeholder="快递公司" data-field="expressCompany" bindinput="onFieldInput" />
|
||||
|
||||
<view class="field-label">取件码</view>
|
||||
<input class="input" placeholder="取件码" data-field="pickupCode" bindinput="onFieldInput" />
|
||||
|
||||
<view class="field-label">快递站地址</view>
|
||||
<input class="input" placeholder="快递站地址" data-field="pickupAddress" bindinput="onFieldInput" />
|
||||
|
||||
<view class="field-label">手机号</view>
|
||||
<input class="input" placeholder="手机号" data-field="receiverPhone" bindinput="onFieldInput" />
|
||||
<view class="muted">送达地址</view>
|
||||
|
||||
<view class="field-label">送达地址</view>
|
||||
<picker mode="selector" range="{{addresses}}" range-key="detail" value="{{addressIndex}}" bindchange="onAddressChange">
|
||||
<view class="picker">{{addresses[addressIndex] ? addresses[addressIndex].building + ' ' + addresses[addressIndex].room + ' ' + addresses[addressIndex].detail : '请选择地址'}}</view>
|
||||
</picker>
|
||||
|
||||
<view class="field-label">件数</view>
|
||||
<input class="input" type="number" value="{{form.packageCount}}" bindinput="onPackageCountChange" />
|
||||
<view class="card">
|
||||
|
||||
<view class="fee-panel">
|
||||
<view class="row between">
|
||||
<text>预估服务费</text>
|
||||
<text class="price">¥{{feeCent / 100}}</text>
|
||||
</view>
|
||||
<view class="muted">首件 3 元,续件每件 1 元</view>
|
||||
</view>
|
||||
|
||||
<view class="field-label">备注</view>
|
||||
<textarea class="textarea" placeholder="备注" data-field="remark" bindinput="onFieldInput" />
|
||||
<button class="primary" loading="{{submitting}}" bindtap="submit">提交代取</button>
|
||||
<button class="primary action-button" loading="{{submitting}}" bindtap="submit">提交代取</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,3 +1,46 @@
|
||||
.card .card {
|
||||
margin-top: 0;
|
||||
.page-heading {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.express-tips {
|
||||
padding: 22rpx;
|
||||
margin-bottom: 18rpx;
|
||||
background: #fff7ed;
|
||||
border: 1rpx solid #fed7aa;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.tips-title {
|
||||
margin-bottom: 8rpx;
|
||||
color: #9a3412;
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.express-form {
|
||||
padding-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.fee-panel {
|
||||
padding: 22rpx;
|
||||
margin-bottom: 20rpx;
|
||||
background: #f8fafc;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.fee-panel .price {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
<view class="page" wx:if="{{groupBuy}}">
|
||||
<view class="card">
|
||||
<view class="section-title">{{groupBuy.title}}</view>
|
||||
<view class="price">¥{{groupBuy.priceCent / 100}}</view>
|
||||
<view class="page detail-page" wx:if="{{groupBuy}}">
|
||||
<view class="detail-hero">
|
||||
<image wx:if="{{groupBuy.coverUrl}}" class="detail-cover" src="{{groupBuy.coverUrl}}" mode="aspectFill" />
|
||||
<view wx:else class="detail-cover media-placeholder">团</view>
|
||||
<view class="detail-info">
|
||||
<view class="tag">{{groupBuy.status}}</view>
|
||||
<view class="detail-title">{{groupBuy.title}}</view>
|
||||
<view class="detail-price">¥{{groupBuy.priceCent / 100}}</view>
|
||||
<view wx:if="{{groupBuy.originPriceCent}}" class="origin-price">原价 ¥{{groupBuy.originPriceCent / 100}}</view>
|
||||
<view class="muted">{{groupBuy.description || '社区团购'}}</view>
|
||||
<view class="muted">自提点:{{groupBuy.pickupAddress}}</view>
|
||||
</view>
|
||||
<view class="card">
|
||||
<view class="muted">数量</view>
|
||||
</view>
|
||||
|
||||
<view class="delivery-panel">
|
||||
<view class="panel-label">自提点</view>
|
||||
<view class="panel-value">{{groupBuy.pickupAddress}}</view>
|
||||
<view class="muted">截单 {{groupBuy.endTime}}</view>
|
||||
</view>
|
||||
|
||||
<view class="purchase-card form-card">
|
||||
<view class="card-title form-title">团购下单</view>
|
||||
<view class="field-label">数量</view>
|
||||
<input class="input" type="number" value="{{form.quantity}}" bindinput="onQuantityChange" />
|
||||
<view class="muted">提货方式</view>
|
||||
<radio-group bindchange="onMethodChange">
|
||||
|
||||
<view class="field-label">提货方式</view>
|
||||
<radio-group class="radio-grid" bindchange="onMethodChange">
|
||||
<label class="radio"><radio value="SELF_PICKUP" checked="{{form.deliveryMethod === 'SELF_PICKUP'}}" />自提</label>
|
||||
<label class="radio"><radio value="IMMEDIATE" checked="{{form.deliveryMethod === 'IMMEDIATE'}}" />配送</label>
|
||||
</radio-group>
|
||||
|
||||
<view wx:if="{{form.deliveryMethod !== 'SELF_PICKUP'}}">
|
||||
<view class="muted">收货地址</view>
|
||||
<view class="field-label">收货地址</view>
|
||||
<picker mode="selector" range="{{addresses}}" range-key="detail" value="{{addressIndex}}" bindchange="onAddressChange">
|
||||
<view class="picker">{{addresses[addressIndex] ? addresses[addressIndex].building + ' ' + addresses[addressIndex].room + ' ' + addresses[addressIndex].detail : '请选择地址'}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="field-label">备注</view>
|
||||
<textarea class="textarea" placeholder="备注" bindinput="onRemarkInput" />
|
||||
<button class="primary" loading="{{submitting}}" bindtap="submit">提交团购订单</button>
|
||||
<button class="primary action-button" loading="{{submitting}}" bindtap="submit">提交团购订单</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,5 +1,91 @@
|
||||
.radio {
|
||||
display: block;
|
||||
height: 60rpx;
|
||||
line-height: 60rpx;
|
||||
.detail-page {
|
||||
padding-bottom: 60rpx;
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
overflow: hidden;
|
||||
margin-bottom: 18rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #e4eaf0;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
|
||||
}
|
||||
|
||||
.detail-cover {
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
}
|
||||
|
||||
.detail-info {
|
||||
padding: 26rpx;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin-top: 14rpx;
|
||||
color: #111827;
|
||||
font-size: 40rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.detail-price {
|
||||
margin-top: 16rpx;
|
||||
color: #c2410c;
|
||||
font-size: 44rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.origin-price {
|
||||
margin-bottom: 12rpx;
|
||||
color: #94a3b8;
|
||||
font-size: 24rpx;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.delivery-panel {
|
||||
padding: 22rpx;
|
||||
margin-bottom: 18rpx;
|
||||
background: #eff6ff;
|
||||
border: 1rpx solid #bfdbfe;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
color: #1d4ed8;
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.panel-value {
|
||||
margin: 8rpx 0;
|
||||
color: #111827;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.purchase-card {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.radio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.radio {
|
||||
min-height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14rpx;
|
||||
color: #334155;
|
||||
background: #f8fafc;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 14rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
<view class="page">
|
||||
<view class="section-title">今日团购</view>
|
||||
<view wx:for="{{groupBuys}}" wx:key="id" class="card" bindtap="goDetail" data-id="{{item.id}}">
|
||||
<view class="row between">
|
||||
<view>
|
||||
<view>{{item.title}}</view>
|
||||
<view class="muted">截单 {{item.endTime}}</view>
|
||||
<view class="page-heading">
|
||||
<view class="heading-eyebrow">社区拼单</view>
|
||||
<view class="heading-title">今日团购</view>
|
||||
<view class="heading-subtitle">集中采购,邻里共享实惠</view>
|
||||
</view>
|
||||
|
||||
<view class="group-list">
|
||||
<view wx:for="{{groupBuys}}" wx:key="id" class="group-card" bindtap="goDetail" data-id="{{item.id}}">
|
||||
<image wx:if="{{item.coverUrl}}" class="group-cover" src="{{item.coverUrl}}" mode="aspectFill" />
|
||||
<view wx:else class="group-cover media-placeholder">团</view>
|
||||
<view class="group-body">
|
||||
<view class="row between">
|
||||
<view class="card-title">{{item.title}}</view>
|
||||
<view class="price">¥{{item.priceCent / 100}}</view>
|
||||
</view>
|
||||
<view class="muted">截单 {{item.endTime}}</view>
|
||||
<view class="stock-line">
|
||||
<view class="stock-track">
|
||||
<view class="stock-fill"></view>
|
||||
</view>
|
||||
<view class="muted">库存 {{item.stock}} / 已售 {{item.soldCount}}</view>
|
||||
</view>
|
||||
<view wx:if="{{!groupBuys.length && !loading}}" class="card muted">暂无团购</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!groupBuys.length && !loading}}" class="empty-state">暂无团购</view>
|
||||
</view>
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
.page-heading {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.group-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.group-card {
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #e4eaf0;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
|
||||
}
|
||||
|
||||
.group-cover {
|
||||
width: 100%;
|
||||
height: 280rpx;
|
||||
}
|
||||
|
||||
.group-body {
|
||||
padding: 22rpx;
|
||||
}
|
||||
|
||||
.price {
|
||||
min-width: 120rpx;
|
||||
text-align: right;
|
||||
font-size: 34rpx;
|
||||
}
|
||||
|
||||
.stock-line {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.stock-track {
|
||||
height: 12rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12rpx;
|
||||
background: #edf2f7;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.stock-fill {
|
||||
width: 46%;
|
||||
height: 100%;
|
||||
background: #0f766e;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,126 @@
|
||||
<view class="page">
|
||||
<view class="section">
|
||||
<view class="section-title">邻小帮</view>
|
||||
<view class="muted">社区服务 30 分钟到家</view>
|
||||
<view class="home-hero">
|
||||
<view>
|
||||
<view class="hero-eyebrow">社区生活服务</view>
|
||||
<view class="hero-title">邻小帮</view>
|
||||
<view class="hero-subtitle">商品预定、快递代取、团购闲置都在这里</view>
|
||||
</view>
|
||||
<view class="hero-badge">30 分钟到家</view>
|
||||
</view>
|
||||
|
||||
<view class="grid section">
|
||||
<view class="shortcut" bindtap="go" data-url="/pages/products/index">商品预定</view>
|
||||
<view class="shortcut" bindtap="go" data-url="/pages/express/create">代取快递</view>
|
||||
<view class="shortcut" bindtap="goTab" data-url="/pages/group-buy/index">今日团购</view>
|
||||
<view class="shortcut" bindtap="goTab" data-url="/pages/second-hand/index">二手闲置</view>
|
||||
<view class="shortcut" bindtap="go" data-url="/pages/notices/index">社区公告</view>
|
||||
<view class="shortcut" bindtap="go" data-url="/pages/orders/index">我的订单</view>
|
||||
<view class="home-search" bindtap="go" data-url="/pages/products/index">
|
||||
<text>搜索社区商品、团购、闲置</text>
|
||||
<text class="search-action">去看看</text>
|
||||
</view>
|
||||
|
||||
<swiper wx:if="{{banners.length}}" class="banner-swiper" indicator-dots autoplay circular>
|
||||
<swiper-item wx:for="{{banners}}" wx:key="id">
|
||||
<view class="banner-card">
|
||||
<image wx:if="{{item.imageUrl}}" src="{{item.imageUrl}}" mode="aspectFill" />
|
||||
<view wx:else class="banner-fallback">邻小帮</view>
|
||||
<view class="banner-mask">
|
||||
<view class="banner-title">{{item.title}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<view class="service-grid section">
|
||||
<view class="shortcut service-card service-product" bindtap="go" data-url="/pages/products/index">
|
||||
<view class="service-icon">货</view>
|
||||
<view class="service-name">商品预定</view>
|
||||
</view>
|
||||
<view class="shortcut service-card service-express" bindtap="go" data-url="/pages/express/create">
|
||||
<view class="service-icon">快</view>
|
||||
<view class="service-name">代取快递</view>
|
||||
</view>
|
||||
<view class="shortcut service-card service-group" bindtap="goTab" data-url="/pages/group-buy/index">
|
||||
<view class="service-icon">团</view>
|
||||
<view class="service-name">今日团购</view>
|
||||
</view>
|
||||
<view class="shortcut service-card service-second" bindtap="goTab" data-url="/pages/second-hand/index">
|
||||
<view class="service-icon">闲</view>
|
||||
<view class="service-name">二手闲置</view>
|
||||
</view>
|
||||
<view class="shortcut service-card service-notice" bindtap="go" data-url="/pages/notices/index">
|
||||
<view class="service-icon">告</view>
|
||||
<view class="service-name">社区公告</view>
|
||||
</view>
|
||||
<view class="shortcut service-card service-order" bindtap="go" data-url="/pages/orders/index">
|
||||
<view class="service-icon">单</view>
|
||||
<view class="service-name">我的订单</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<view class="section-title">公告</view>
|
||||
<view wx:for="{{notices}}" wx:key="id" class="card" bindtap="go" data-url="/pages/notices/index">
|
||||
<view>{{item.title}}</view>
|
||||
<view class="pill" bindtap="go" data-url="/pages/notices/index">全部</view>
|
||||
</view>
|
||||
<view wx:for="{{notices}}" wx:key="id" class="content-card notice-brief" bindtap="go" data-url="/pages/notices/index">
|
||||
<view class="card-title">{{item.title}}</view>
|
||||
<view class="muted">{{item.category}}</view>
|
||||
</view>
|
||||
<view wx:if="{{!notices.length}}" class="card muted">暂无公告</view>
|
||||
<view wx:if="{{!notices.length}}" class="empty-state">暂无公告</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<view class="section-title">热门商品</view>
|
||||
<view wx:for="{{products}}" wx:key="id" class="card" bindtap="go" data-url="/pages/products/detail?id={{item.id}}">
|
||||
<view class="row between">
|
||||
<view>{{item.name}}</view>
|
||||
<view class="pill" bindtap="go" data-url="/pages/products/index">去选购</view>
|
||||
</view>
|
||||
<view wx:for="{{products}}" wx:key="id" class="content-card product-brief" bindtap="go" data-url="/pages/products/detail?id={{item.id}}">
|
||||
<view class="brief-main">
|
||||
<image wx:if="{{item.coverUrl}}" class="brief-cover" src="{{item.coverUrl}}" mode="aspectFill" />
|
||||
<view wx:else class="brief-cover media-placeholder">货</view>
|
||||
<view class="brief-body">
|
||||
<view class="card-title">{{item.name}}</view>
|
||||
<view class="muted">{{item.description || '社区精选商品'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="brief-side">
|
||||
<view class="tag">{{item.status}}</view>
|
||||
<view wx:if="{{item.skus.length}}" class="price">¥{{item.skus[0].priceCent / 100}} 起</view>
|
||||
</view>
|
||||
<view class="muted">{{item.description || '社区商品'}}</view>
|
||||
</view>
|
||||
<view wx:if="{{!products.length}}" class="empty-state">暂无热门商品</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<view class="section-title">今日团购</view>
|
||||
<view wx:for="{{groupBuys}}" wx:key="id" class="card" bindtap="go" data-url="/pages/group-buy/detail?id={{item.id}}">
|
||||
<view class="row between">
|
||||
<view>{{item.title}}</view>
|
||||
<view class="price">¥{{item.priceCent / 100}}</view>
|
||||
<view class="pill" bindtap="goTab" data-url="/pages/group-buy/index">更多</view>
|
||||
</view>
|
||||
<view wx:for="{{groupBuys}}" wx:key="id" class="content-card group-brief" bindtap="go" data-url="/pages/group-buy/detail?id={{item.id}}">
|
||||
<view class="brief-main">
|
||||
<image wx:if="{{item.coverUrl}}" class="brief-cover" src="{{item.coverUrl}}" mode="aspectFill" />
|
||||
<view wx:else class="brief-cover media-placeholder">团</view>
|
||||
<view class="brief-body">
|
||||
<view class="card-title">{{item.title}}</view>
|
||||
<view class="muted">已售 {{item.soldCount}} / 库存 {{item.stock}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-title">最新闲置</view>
|
||||
<view wx:for="{{secondGoods}}" wx:key="id" class="card">
|
||||
<view class="row between">
|
||||
<view>{{item.title}}</view>
|
||||
<view class="price">¥{{item.priceCent / 100}}</view>
|
||||
</view>
|
||||
<view wx:if="{{!groupBuys.length}}" class="empty-state">暂无团购</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<view class="section-title">最新闲置</view>
|
||||
<view class="pill" bindtap="goTab" data-url="/pages/second-hand/index">逛闲置</view>
|
||||
</view>
|
||||
<view wx:for="{{secondGoods}}" wx:key="id" class="content-card second-brief">
|
||||
<view class="brief-main">
|
||||
<image wx:if="{{item.imageUrls.length}}" class="brief-cover" src="{{item.imageUrls[0]}}" mode="aspectFill" />
|
||||
<view wx:else class="brief-cover media-placeholder">闲</view>
|
||||
<view class="brief-body">
|
||||
<view class="card-title">{{item.title}}</view>
|
||||
<view class="muted">{{item.category}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="price">¥{{item.priceCent / 100}}</view>
|
||||
</view>
|
||||
<view wx:if="{{!secondGoods.length}}" class="empty-state">暂无闲置</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,3 +1,218 @@
|
||||
.page {
|
||||
padding-bottom: 40rpx;
|
||||
padding-bottom: 56rpx;
|
||||
}
|
||||
|
||||
.home-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
min-height: 220rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
color: #ffffff;
|
||||
background: #0f766e;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 14rpx 34rpx rgba(15, 118, 110, 0.22);
|
||||
}
|
||||
|
||||
.hero-eyebrow {
|
||||
display: inline-flex;
|
||||
height: 42rpx;
|
||||
align-items: center;
|
||||
padding: 0 14rpx;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin-top: 22rpx;
|
||||
font-size: 48rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
margin-top: 14rpx;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 25rpx;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 10rpx 16rpx;
|
||||
color: #854d0e;
|
||||
background: #fef3c7;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.home-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 82rpx;
|
||||
padding: 0 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
color: #64748b;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #dfe7ee;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.search-action {
|
||||
color: #0f766e;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.banner-swiper {
|
||||
height: 220rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.banner-card {
|
||||
position: relative;
|
||||
height: 220rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 16rpx;
|
||||
background: #33536f;
|
||||
}
|
||||
|
||||
.banner-card image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.banner-fallback {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 32rpx;
|
||||
color: #ffffff;
|
||||
background: #33536f;
|
||||
font-size: 36rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.banner-mask {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 34rpx 28rpx 24rpx;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(17, 24, 39, 0.68));
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
width: 100%;
|
||||
height: 150rpx;
|
||||
align-items: flex-start;
|
||||
padding: 20rpx;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
box-shadow: 0 8rpx 20rpx rgba(20, 36, 58, 0.05);
|
||||
}
|
||||
|
||||
.service-icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 14rpx;
|
||||
color: #ffffff;
|
||||
border-radius: 14rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
color: #111827;
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.service-product .service-icon {
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.service-express .service-icon {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.service-group .service-icon {
|
||||
background: #c2410c;
|
||||
}
|
||||
|
||||
.service-second .service-icon {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
.service-notice .service-icon {
|
||||
background: #33536f;
|
||||
}
|
||||
|
||||
.service-order .service-icon {
|
||||
background: #be123c;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 22rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #e4eaf0;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
|
||||
}
|
||||
|
||||
.notice-brief {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brief-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.brief-cover {
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.brief-body {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.brief-side {
|
||||
min-width: 112rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
<view class="page">
|
||||
<view class="section-title">社区公告</view>
|
||||
<view wx:for="{{notices}}" wx:key="id" class="card">
|
||||
<view class="row between">
|
||||
<view>{{item.title}}</view>
|
||||
<view class="page-heading">
|
||||
<view class="heading-eyebrow">社区信息</view>
|
||||
<view class="heading-title">社区公告</view>
|
||||
<view class="heading-subtitle">物业通知、活动消息和服务说明</view>
|
||||
</view>
|
||||
|
||||
<view class="notice-list">
|
||||
<view wx:for="{{notices}}" wx:key="id" class="notice-card">
|
||||
<view class="row between notice-head">
|
||||
<view class="card-title">{{item.title}}</view>
|
||||
<view wx:if="{{item.pinned}}" class="tag">置顶</view>
|
||||
</view>
|
||||
<view class="muted">{{item.category}} {{item.publishedAt}}</view>
|
||||
<view>{{item.content}}</view>
|
||||
<view class="notice-meta">
|
||||
<view class="pill">{{item.category}}</view>
|
||||
<view class="muted">{{item.publishedAt}}</view>
|
||||
</view>
|
||||
<view wx:if="{{!notices.length && !loading}}" class="card muted">暂无公告</view>
|
||||
<view class="notice-content">{{item.content}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!notices.length && !loading}}" class="empty-state">暂无公告</view>
|
||||
</view>
|
||||
|
||||
@@ -1,4 +1,37 @@
|
||||
.card > view:last-child {
|
||||
margin-top: 12rpx;
|
||||
line-height: 44rpx;
|
||||
.page-heading {
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.notice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.notice-card {
|
||||
padding: 24rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #e4eaf0;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
|
||||
}
|
||||
|
||||
.notice-head {
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.notice-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin: 16rpx 0;
|
||||
}
|
||||
|
||||
.notice-content {
|
||||
color: #334155;
|
||||
font-size: 27rpx;
|
||||
line-height: 46rpx;
|
||||
padding-top: 18rpx;
|
||||
border-top: 1rpx solid #edf1f5;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
<view class="page" wx:if="{{order}}">
|
||||
<view class="card">
|
||||
<view class="section-title">{{order.orderNo}}</view>
|
||||
<view class="tag">{{order.statusText}}</view>
|
||||
<view class="summary-card">
|
||||
<view>
|
||||
<view class="muted">订单编号</view>
|
||||
<view class="summary-title">{{order.orderNo}}</view>
|
||||
<view class="muted">类型:{{type}}</view>
|
||||
</view>
|
||||
<view class="card">
|
||||
<view class="tag">{{order.statusText}}</view>
|
||||
</view>
|
||||
|
||||
<view class="detail-card">
|
||||
<view wx:if="{{type === 'goods'}}">
|
||||
<view>商品金额:{{order.totalAmountText}}</view>
|
||||
<view>配送费:{{order.deliveryFeeText}}</view>
|
||||
<view class="price">应收:{{order.payableAmountText}}</view>
|
||||
<view class="detail-line"><text>商品金额</text><text>{{order.totalAmountText}}</text></view>
|
||||
<view class="detail-line"><text>配送费</text><text>{{order.deliveryFeeText}}</text></view>
|
||||
<view class="detail-line total"><text>应收</text><text class="price">{{order.payableAmountText}}</text></view>
|
||||
<view class="muted">{{order.deliveryMethod}}</view>
|
||||
</view>
|
||||
<view wx:if="{{type === 'express'}}">
|
||||
<view>{{order.expressCompany}} / {{order.pickupCode}}</view>
|
||||
<view class="muted">{{order.pickupAddress}}</view>
|
||||
<view class="price">服务费:{{order.feeText}}</view>
|
||||
<view class="detail-line"><text>快递信息</text><text>{{order.expressCompany}} / {{order.pickupCode}}</text></view>
|
||||
<view class="detail-line"><text>取件地址</text><text>{{order.pickupAddress}}</text></view>
|
||||
<view class="detail-line total"><text>服务费</text><text class="price">{{order.feeText}}</text></view>
|
||||
</view>
|
||||
<view wx:if="{{type === 'group'}}">
|
||||
<view>{{order.groupBuyTitle}}</view>
|
||||
<view>数量:{{order.quantity}}</view>
|
||||
<view class="price">金额:{{order.amountText}}</view>
|
||||
<view class="detail-line"><text>团购商品</text><text>{{order.groupBuyTitle}}</text></view>
|
||||
<view class="detail-line"><text>数量</text><text>{{order.quantity}}</text></view>
|
||||
<view class="detail-line total"><text>金额</text><text class="price">{{order.amountText}}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="button-row">
|
||||
<button class="ghost" bindtap="cancel">取消订单</button>
|
||||
<button wx:if="{{type === 'express'}}" class="primary" bindtap="completeExpress">确认完成</button>
|
||||
<button class="ghost action-button" bindtap="cancel">取消订单</button>
|
||||
<button wx:if="{{type === 'express'}}" class="primary action-button" bindtap="completeExpress">确认完成</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,3 +1,58 @@
|
||||
.summary-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 18rpx;
|
||||
color: #ffffff;
|
||||
background: #33536f;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.summary-card .muted {
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
margin: 10rpx 0;
|
||||
font-size: 36rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #e4eaf0;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
|
||||
}
|
||||
|
||||
.detail-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
padding: 18rpx 0;
|
||||
color: #334155;
|
||||
border-bottom: 1rpx solid #edf1f5;
|
||||
}
|
||||
|
||||
.detail-line text:first-child {
|
||||
flex-shrink: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.detail-line text:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.detail-line.total {
|
||||
border-bottom: none;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.button-row button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<view class="page">
|
||||
<view class="section-title">我的订单</view>
|
||||
<view wx:for="{{orders}}" wx:key="id" class="card" bindtap="goDetail" data-type="{{item.type}}" data-id="{{item.id}}">
|
||||
<view class="row between">
|
||||
<view>
|
||||
<view>{{item.title}}</view>
|
||||
<view class="muted">{{item.typeText}} {{item.orderNo}}</view>
|
||||
<view class="page-heading">
|
||||
<view class="heading-eyebrow">履约进度</view>
|
||||
<view class="heading-title">我的订单</view>
|
||||
<view class="heading-subtitle">查看商品、快递和团购订单</view>
|
||||
</view>
|
||||
|
||||
<view class="order-list">
|
||||
<view wx:for="{{orders}}" wx:key="id" class="order-card" bindtap="goDetail" data-type="{{item.type}}" data-id="{{item.id}}">
|
||||
<view class="row between order-head">
|
||||
<view class="order-title-wrap">
|
||||
<view class="card-title">{{item.title}}</view>
|
||||
<view class="order-meta">{{item.typeText}} {{item.orderNo}}</view>
|
||||
</view>
|
||||
<view class="tag">{{item.statusText}}</view>
|
||||
</view>
|
||||
<view class="row between">
|
||||
<view class="order-footer row between">
|
||||
<view class="muted">状态:{{item.statusText}}</view>
|
||||
<view class="price">{{item.amountText}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{!orders.length && !loading}}" class="card muted">暂无订单</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!orders.length && !loading}}" class="empty-state">暂无订单</view>
|
||||
</view>
|
||||
|
||||
@@ -1,3 +1,42 @@
|
||||
.page-heading {
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
padding: 24rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #e4eaf0;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
|
||||
}
|
||||
|
||||
.order-head {
|
||||
align-items: flex-start;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.order-title-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.order-meta {
|
||||
margin-top: 8rpx;
|
||||
color: #64748b;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.order-footer {
|
||||
margin-top: 20rpx;
|
||||
padding-top: 18rpx;
|
||||
border-top: 1rpx solid #edf1f5;
|
||||
}
|
||||
|
||||
.tag {
|
||||
min-width: 120rpx;
|
||||
text-align: center;
|
||||
|
||||
68
miniapp/miniprogram/pages/pageStyles.test.js
Normal file
68
miniapp/miniprogram/pages/pageStyles.test.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
|
||||
function readPage(page, ext, name = 'index') {
|
||||
return fs.readFileSync(path.join(root, 'pages', page, `${name}.${ext}`), 'utf8')
|
||||
}
|
||||
|
||||
function readPageFile(page, name, ext) {
|
||||
return fs.readFileSync(path.join(root, 'pages', page, `${name}.${ext}`), 'utf8')
|
||||
}
|
||||
|
||||
function selectorExists(wxss, selector) {
|
||||
return new RegExp(`${selector.replace('.', '\\.')}\\s*[{,]`).test(wxss)
|
||||
}
|
||||
|
||||
describe('miniapp page styling', () => {
|
||||
const pageStyleExpectations = [
|
||||
['home', 'index', ['.home-hero', '.service-grid', '.banner-card', '.content-card']],
|
||||
['products', 'index', ['.page-heading', '.product-card', '.product-thumb', '.category-item']],
|
||||
['express', 'create', ['.page-heading', '.form-card', '.fee-panel', '.field-label']],
|
||||
['group-buy', 'index', ['.page-heading', '.group-card', '.group-cover', '.stock-line']],
|
||||
['second-hand', 'index', ['.page-heading', '.second-card', '.second-cover', '.publish-button']],
|
||||
['notices', 'index', ['.page-heading', '.notice-card', '.notice-content']],
|
||||
['orders', 'index', ['.page-heading', '.order-card', '.order-meta']],
|
||||
['profile', 'index', ['.profile-hero', '.menu-card', '.menu-icon']],
|
||||
['address', 'index', ['.page-heading', '.address-card', '.address-form']]
|
||||
]
|
||||
|
||||
test.each(pageStyleExpectations)('%s page has dedicated visual styles', (page, name, selectors) => {
|
||||
const wxss = readPage(page, 'wxss', name)
|
||||
|
||||
expect(wxss.length).toBeGreaterThan(300)
|
||||
selectors.forEach((selector) => {
|
||||
expect(selectorExists(wxss, selector)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test('detail pages have order and purchase layouts', () => {
|
||||
const productDetail = readPageFile('products', 'detail', 'wxss')
|
||||
const groupDetail = readPageFile('group-buy', 'detail', 'wxss')
|
||||
const orderDetail = readPageFile('orders', 'detail', 'wxss')
|
||||
|
||||
expect(selectorExists(productDetail, '.detail-hero')).toBe(true)
|
||||
expect(selectorExists(productDetail, '.purchase-card')).toBe(true)
|
||||
expect(selectorExists(groupDetail, '.detail-hero')).toBe(true)
|
||||
expect(selectorExists(groupDetail, '.purchase-card')).toBe(true)
|
||||
expect(selectorExists(orderDetail, '.summary-card')).toBe(true)
|
||||
expect(selectorExists(orderDetail, '.detail-line')).toBe(true)
|
||||
})
|
||||
|
||||
test('global stylesheet provides shared design primitives', () => {
|
||||
const appWxss = fs.readFileSync(path.join(root, 'app.wxss'), 'utf8')
|
||||
|
||||
;[
|
||||
'.page-heading',
|
||||
'.card-title',
|
||||
'.empty-state',
|
||||
'.form-card',
|
||||
'.action-button',
|
||||
'.pill',
|
||||
'.media-placeholder'
|
||||
].forEach((selector) => {
|
||||
expect(selectorExists(appWxss, selector)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,34 +1,48 @@
|
||||
<view class="page" wx:if="{{product}}">
|
||||
<view class="card">
|
||||
<view class="section-title">{{product.name}}</view>
|
||||
<view class="page detail-page" wx:if="{{product}}">
|
||||
<view class="detail-hero">
|
||||
<image wx:if="{{product.coverUrl}}" class="detail-cover" src="{{product.coverUrl}}" mode="aspectFill" />
|
||||
<view wx:else class="detail-cover media-placeholder">货</view>
|
||||
<view class="detail-info">
|
||||
<view class="tag">{{product.status}}</view>
|
||||
<view class="detail-title">{{product.name}}</view>
|
||||
<view class="muted">{{product.description || '社区商品'}}</view>
|
||||
<view wx:if="{{product.skus.length}}" class="detail-price">¥{{product.skus[skuIndex].priceCent / 100}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="muted">规格</view>
|
||||
<view class="purchase-card form-card" wx:if="{{product.skus.length}}">
|
||||
<view class="card-title form-title">下单信息</view>
|
||||
<view class="field-label">规格</view>
|
||||
<picker mode="selector" range="{{product.skus}}" range-key="skuName" value="{{skuIndex}}" bindchange="onSkuChange">
|
||||
<view class="picker">{{product.skus[skuIndex].skuName}} / ¥{{product.skus[skuIndex].priceCent / 100}} / 库存 {{product.skus[skuIndex].stock}}</view>
|
||||
</picker>
|
||||
<view class="muted">数量</view>
|
||||
|
||||
<view class="field-label">数量</view>
|
||||
<input class="input" type="number" value="{{form.quantity}}" bindinput="onQuantityChange" />
|
||||
<view class="muted">配送方式</view>
|
||||
<radio-group bindchange="onMethodChange">
|
||||
|
||||
<view class="field-label">配送方式</view>
|
||||
<radio-group class="radio-grid" bindchange="onMethodChange">
|
||||
<label class="radio"><radio value="IMMEDIATE" checked="{{form.deliveryMethod === 'IMMEDIATE'}}" />立即配送</label>
|
||||
<label class="radio"><radio value="SCHEDULED" checked="{{form.deliveryMethod === 'SCHEDULED'}}" />预约配送</label>
|
||||
<label class="radio"><radio value="SELF_PICKUP" checked="{{form.deliveryMethod === 'SELF_PICKUP'}}" />自提</label>
|
||||
</radio-group>
|
||||
|
||||
<view wx:if="{{form.deliveryMethod !== 'SELF_PICKUP'}}">
|
||||
<view class="muted">收货地址</view>
|
||||
<view class="field-label">收货地址</view>
|
||||
<picker mode="selector" range="{{addresses}}" range-key="detail" value="{{addressIndex}}" bindchange="onAddressChange">
|
||||
<view class="picker">{{addresses[addressIndex] ? addresses[addressIndex].building + ' ' + addresses[addressIndex].room + ' ' + addresses[addressIndex].detail : '请选择地址'}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{form.deliveryMethod === 'SCHEDULED'}}">
|
||||
<view class="muted">预约时间</view>
|
||||
<view class="field-label">预约时间</view>
|
||||
<input class="input" placeholder="2026-07-07T18:30:00" bindinput="onScheduledTimeChange" />
|
||||
</view>
|
||||
<view class="muted">备注</view>
|
||||
<textarea class="textarea" bindinput="onRemarkInput" />
|
||||
<button class="primary" loading="{{submitting}}" bindtap="submit">提交订单</button>
|
||||
|
||||
<view class="field-label">备注</view>
|
||||
<textarea class="textarea" placeholder="口味、送达说明等" bindinput="onRemarkInput" />
|
||||
<button class="primary action-button" loading="{{submitting}}" bindtap="submit">提交订单</button>
|
||||
</view>
|
||||
|
||||
<view wx:else class="empty-state">暂无可选规格</view>
|
||||
</view>
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
.radio {
|
||||
display: block;
|
||||
height: 60rpx;
|
||||
line-height: 60rpx;
|
||||
.detail-page {
|
||||
padding-bottom: 60rpx;
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
overflow: hidden;
|
||||
margin-bottom: 22rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #e4eaf0;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
|
||||
}
|
||||
|
||||
.detail-cover {
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
}
|
||||
|
||||
.detail-info {
|
||||
padding: 26rpx;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin-top: 14rpx;
|
||||
color: #111827;
|
||||
font-size: 40rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.detail-price {
|
||||
margin-top: 18rpx;
|
||||
color: #c2410c;
|
||||
font-size: 42rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.purchase-card {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.radio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.radio {
|
||||
min-height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12rpx;
|
||||
background: #f8fafc;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 14rpx;
|
||||
color: #334155;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ Page({
|
||||
loading: false,
|
||||
categories: [],
|
||||
activeCategoryId: null,
|
||||
keyword: '',
|
||||
products: []
|
||||
},
|
||||
|
||||
@@ -14,7 +15,7 @@ Page({
|
||||
|
||||
load() {
|
||||
this.setData({ loading: true })
|
||||
Promise.all([listCategories(), listProducts()])
|
||||
Promise.all([listCategories(), listProducts(null, this.data.keyword)])
|
||||
.then(([categories, products]) => {
|
||||
this.setData({
|
||||
categories: categories || [],
|
||||
@@ -28,14 +29,24 @@ Page({
|
||||
chooseCategory(event) {
|
||||
const categoryId = event.currentTarget.dataset.id
|
||||
this.setData({ activeCategoryId: categoryId })
|
||||
listProducts(categoryId)
|
||||
listProducts(categoryId, this.data.keyword)
|
||||
.then((products) => this.setData({ products: products || [] }))
|
||||
.catch((error) => wx.showToast({ title: error.message, icon: 'none' }))
|
||||
},
|
||||
|
||||
clearCategory() {
|
||||
this.setData({ activeCategoryId: null })
|
||||
listProducts()
|
||||
listProducts(null, this.data.keyword)
|
||||
.then((products) => this.setData({ products: products || [] }))
|
||||
.catch((error) => wx.showToast({ title: error.message, icon: 'none' }))
|
||||
},
|
||||
|
||||
onKeywordInput(event) {
|
||||
this.setData({ keyword: event.detail.value })
|
||||
},
|
||||
|
||||
search() {
|
||||
listProducts(this.data.activeCategoryId, this.data.keyword)
|
||||
.then((products) => this.setData({ products: products || [] }))
|
||||
.catch((error) => wx.showToast({ title: error.message, icon: 'none' }))
|
||||
},
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<view class="page">
|
||||
<view class="section-title">商品预定</view>
|
||||
<view class="page-heading">
|
||||
<view class="heading-eyebrow">社区便利店</view>
|
||||
<view class="heading-title">商品预定</view>
|
||||
<view class="heading-subtitle">挑好商品,社区内配送或自提</view>
|
||||
</view>
|
||||
|
||||
<view class="search-row">
|
||||
<input class="input search-input" placeholder="搜索商品" value="{{keyword}}" bindinput="onKeywordInput" confirm-type="search" bindconfirm="search" />
|
||||
<button size="mini" class="primary search-button" bindtap="search">搜索</button>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-x class="category-scroll">
|
||||
<view class="category-item {{!activeCategoryId ? 'active' : ''}}" bindtap="clearCategory">全部</view>
|
||||
<view
|
||||
@@ -13,16 +23,24 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view wx:for="{{products}}" wx:key="id" class="card" bindtap="goDetail" data-id="{{item.id}}">
|
||||
<view class="product-list">
|
||||
<view wx:for="{{products}}" wx:key="id" class="product-card" bindtap="goDetail" data-id="{{item.id}}">
|
||||
<image wx:if="{{item.coverUrl}}" class="product-thumb" src="{{item.coverUrl}}" mode="aspectFill" />
|
||||
<view wx:else class="product-thumb media-placeholder">货</view>
|
||||
<view class="product-info">
|
||||
<view class="row between">
|
||||
<view>
|
||||
<view>{{item.name}}</view>
|
||||
<view class="muted">{{item.description || '社区精选商品'}}</view>
|
||||
</view>
|
||||
<view class="card-title">{{item.name}}</view>
|
||||
<view class="tag">{{item.status}}</view>
|
||||
</view>
|
||||
<view class="muted product-desc">{{item.description || '社区精选商品'}}</view>
|
||||
<view class="row between product-footer">
|
||||
<view wx:if="{{item.unitName}}" class="pill">{{item.unitName}}</view>
|
||||
<view wx:else class="pill">社区好物</view>
|
||||
<view wx:if="{{item.skus.length}}" class="price">¥{{item.skus[0].priceCent / 100}} 起</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!products.length && !loading}}" class="card muted">暂无商品</view>
|
||||
<view wx:if="{{!products.length && !loading}}" class="empty-state">暂无商品</view>
|
||||
</view>
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
.page-heading {
|
||||
padding-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.category-scroll {
|
||||
white-space: nowrap;
|
||||
margin-bottom: 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
align-items: center;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
width: 132rpx;
|
||||
margin: 0;
|
||||
min-height: 80rpx;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
@@ -8,12 +30,15 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 120rpx;
|
||||
height: 64rpx;
|
||||
padding: 0 20rpx;
|
||||
height: 68rpx;
|
||||
padding: 0 24rpx;
|
||||
margin-right: 12rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #d9e0e8;
|
||||
border-radius: 6rpx;
|
||||
border-radius: 999rpx;
|
||||
color: #334155;
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.category-item.active {
|
||||
@@ -21,3 +46,43 @@
|
||||
background: #0f766e;
|
||||
border-color: #0f766e;
|
||||
}
|
||||
|
||||
.product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #e4eaf0;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
|
||||
}
|
||||
|
||||
.product-thumb {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.product-desc {
|
||||
min-height: 64rpx;
|
||||
}
|
||||
|
||||
.product-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
<view class="page">
|
||||
<view class="card">
|
||||
<view class="section-title">我的</view>
|
||||
<view class="muted">邻小帮居民端</view>
|
||||
<view class="profile-hero">
|
||||
<view class="avatar">邻</view>
|
||||
<view>
|
||||
<view class="profile-title">我的</view>
|
||||
<view class="profile-subtitle">邻小帮居民端</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-list">
|
||||
<view class="menu-card" bindtap="go" data-url="/pages/orders/index">
|
||||
<view class="menu-icon icon-order">单</view>
|
||||
<view class="menu-text">我的订单</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
<view class="menu-card" bindtap="go" data-url="/pages/address/index">
|
||||
<view class="menu-icon icon-address">址</view>
|
||||
<view class="menu-text">地址管理</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
<view class="menu-card" bindtap="go" data-url="/pages/express/create">
|
||||
<view class="menu-icon icon-express">快</view>
|
||||
<view class="menu-text">代取快递</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
<view class="menu-card" bindtap="go" data-url="/pages/products/index">
|
||||
<view class="menu-icon icon-product">货</view>
|
||||
<view class="menu-text">商品预定</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
<view class="menu-card" bindtap="go" data-url="/pages/notices/index">
|
||||
<view class="menu-icon icon-notice">告</view>
|
||||
<view class="menu-text">社区公告</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card menu" bindtap="go" data-url="/pages/orders/index">我的订单</view>
|
||||
<view class="card menu" bindtap="go" data-url="/pages/address/index">地址管理</view>
|
||||
<view class="card menu" bindtap="go" data-url="/pages/express/create">代取快递</view>
|
||||
<view class="card menu" bindtap="go" data-url="/pages/products/index">商品预定</view>
|
||||
<view class="card menu" bindtap="go" data-url="/pages/notices/index">社区公告</view>
|
||||
</view>
|
||||
|
||||
@@ -1,3 +1,99 @@
|
||||
.menu {
|
||||
font-weight: 600;
|
||||
.profile-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 22rpx;
|
||||
color: #ffffff;
|
||||
background: #0f766e;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 14rpx 34rpx rgba(15, 118, 110, 0.2);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #0f766e;
|
||||
background: #ffffff;
|
||||
border-radius: 50%;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.profile-subtitle {
|
||||
margin-top: 8rpx;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.menu-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
min-height: 104rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #e4eaf0;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(20, 36, 58, 0.05);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
flex: 1;
|
||||
color: #111827;
|
||||
font-size: 29rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
color: #94a3b8;
|
||||
font-size: 42rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.icon-order {
|
||||
background: #be123c;
|
||||
}
|
||||
|
||||
.icon-address {
|
||||
background: #33536f;
|
||||
}
|
||||
|
||||
.icon-express {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.icon-product {
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.icon-notice {
|
||||
background: #c2410c;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const { createSecondGoods } = require('../../api/secondGoodsApi')
|
||||
const { setField } = require('../../utils/request')
|
||||
const { setField, uploadFile } = require('../../utils/request')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
submitting: false,
|
||||
imageText: '',
|
||||
uploading: false,
|
||||
form: {
|
||||
title: '',
|
||||
priceCent: 0,
|
||||
@@ -28,14 +28,43 @@ Page({
|
||||
this.setData({ 'form.tradeMethod': event.detail.value })
|
||||
},
|
||||
|
||||
onImageInput(event) {
|
||||
const imageText = event.detail.value
|
||||
this.setData({
|
||||
imageText,
|
||||
'form.imageUrls': imageText ? imageText.split(',').map((item) => item.trim()).filter(Boolean) : []
|
||||
chooseImages() {
|
||||
if (this.data.uploading) {
|
||||
return
|
||||
}
|
||||
wx.chooseMedia({
|
||||
count: Math.max(1, 6 - this.data.form.imageUrls.length),
|
||||
mediaType: ['image'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (result) => {
|
||||
const files = result.tempFiles || []
|
||||
this.uploadImages(files.map((item) => item.tempFilePath))
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
uploadImages(filePaths) {
|
||||
if (!filePaths.length) {
|
||||
return
|
||||
}
|
||||
this.setData({ uploading: true })
|
||||
Promise.all(filePaths.map((filePath) => uploadFile(filePath)))
|
||||
.then((results) => {
|
||||
const urls = results.map((item) => item.url).filter(Boolean)
|
||||
this.setData({
|
||||
'form.imageUrls': this.data.form.imageUrls.concat(urls).slice(0, 6)
|
||||
})
|
||||
})
|
||||
.catch((error) => wx.showToast({ title: error.message, icon: 'none' }))
|
||||
.finally(() => this.setData({ uploading: false }))
|
||||
},
|
||||
|
||||
removeImage(event) {
|
||||
const index = Number(event.currentTarget.dataset.index)
|
||||
const next = this.data.form.imageUrls.filter((item, itemIndex) => itemIndex !== index)
|
||||
this.setData({ 'form.imageUrls': next })
|
||||
},
|
||||
|
||||
submit() {
|
||||
if (this.data.submitting) {
|
||||
return
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
<view class="page">
|
||||
<view class="section-title">发布闲置</view>
|
||||
<view class="card">
|
||||
<view class="page-heading">
|
||||
<view class="heading-eyebrow">发布审核</view>
|
||||
<view class="heading-title">发布闲置</view>
|
||||
<view class="heading-subtitle">填写真实信息,审核通过后展示给邻居</view>
|
||||
</view>
|
||||
|
||||
<view class="form-card publish-form">
|
||||
<view class="field-label">标题</view>
|
||||
<input class="input" placeholder="标题" data-field="title" bindinput="onFieldInput" />
|
||||
|
||||
<view class="field-label">价格</view>
|
||||
<input class="input" placeholder="价格(元)" type="digit" bindinput="onPriceInput" />
|
||||
|
||||
<view class="field-label">分类</view>
|
||||
<input class="input" placeholder="分类" data-field="category" bindinput="onFieldInput" />
|
||||
|
||||
<view class="field-label">联系电话</view>
|
||||
<input class="input" placeholder="联系电话" data-field="contactPhone" bindinput="onFieldInput" />
|
||||
<radio-group bindchange="onTradeMethodChange">
|
||||
|
||||
<view class="field-label">交易方式</view>
|
||||
<radio-group class="radio-grid" bindchange="onTradeMethodChange">
|
||||
<label class="radio"><radio value="FACE_TO_FACE" checked="{{form.tradeMethod === 'FACE_TO_FACE'}}" />面交</label>
|
||||
<label class="radio"><radio value="DELIVERY" checked="{{form.tradeMethod === 'DELIVERY'}}" />可配送</label>
|
||||
</radio-group>
|
||||
<input class="input" placeholder="图片 URL,多个用英文逗号分隔" value="{{imageText}}" bindinput="onImageInput" />
|
||||
|
||||
<view class="field-label">图片</view>
|
||||
<view class="image-grid">
|
||||
<view wx:for="{{form.imageUrls}}" wx:key="*this" class="image-item">
|
||||
<image src="{{item}}" mode="aspectFill" />
|
||||
<button size="mini" class="remove" data-index="{{index}}" bindtap="removeImage">删除</button>
|
||||
</view>
|
||||
<button wx:if="{{form.imageUrls.length < 6}}" class="image-picker" loading="{{uploading}}" bindtap="chooseImages">上传图片</button>
|
||||
</view>
|
||||
|
||||
<view class="field-label">描述</view>
|
||||
<textarea class="textarea" placeholder="描述" data-field="description" bindinput="onFieldInput" />
|
||||
<button class="primary" loading="{{submitting}}" bindtap="submit">提交审核</button>
|
||||
<button class="primary action-button" loading="{{submitting}}" bindtap="submit">提交审核</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,5 +1,78 @@
|
||||
.radio {
|
||||
display: block;
|
||||
height: 60rpx;
|
||||
line-height: 60rpx;
|
||||
.page-heading {
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.publish-form {
|
||||
padding-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.radio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.radio {
|
||||
min-height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14rpx;
|
||||
color: #334155;
|
||||
background: #f8fafc;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 14rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12rpx;
|
||||
margin: 20rpx 0;
|
||||
}
|
||||
|
||||
.image-item,
|
||||
.image-picker {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 14rpx;
|
||||
background: #eef3f7;
|
||||
}
|
||||
|
||||
.image-item image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.remove {
|
||||
position: absolute;
|
||||
right: 8rpx;
|
||||
bottom: 8rpx;
|
||||
margin: 0;
|
||||
font-size: 22rpx;
|
||||
color: #ffffff;
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
}
|
||||
|
||||
.image-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1rpx dashed #8aa0b5;
|
||||
color: #33536f;
|
||||
background: #f8fafc;
|
||||
border-radius: 14rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const { listSecondGoods } = require('../../api/secondGoodsApi')
|
||||
Page({
|
||||
data: {
|
||||
loading: false,
|
||||
keyword: '',
|
||||
goods: []
|
||||
},
|
||||
|
||||
@@ -16,12 +17,20 @@ Page({
|
||||
|
||||
load() {
|
||||
this.setData({ loading: true })
|
||||
listSecondGoods()
|
||||
listSecondGoods(null, this.data.keyword)
|
||||
.then((goods) => this.setData({ goods: goods || [] }))
|
||||
.catch((error) => wx.showToast({ title: error.message, icon: 'none' }))
|
||||
.finally(() => this.setData({ loading: false }))
|
||||
},
|
||||
|
||||
onKeywordInput(event) {
|
||||
this.setData({ keyword: event.detail.value })
|
||||
},
|
||||
|
||||
search() {
|
||||
this.load()
|
||||
},
|
||||
|
||||
goCreate() {
|
||||
wx.navigateTo({ url: '/pages/second-hand/create' })
|
||||
},
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
<view class="page">
|
||||
<view class="row between section">
|
||||
<view class="section-title">二手闲置</view>
|
||||
<button size="mini" class="primary" bindtap="goCreate">发布</button>
|
||||
</view>
|
||||
<view wx:for="{{goods}}" wx:key="id" class="card">
|
||||
<view class="row between">
|
||||
<view class="page-heading row between">
|
||||
<view>
|
||||
<view>{{item.title}}</view>
|
||||
<view class="muted">{{item.category}} / {{item.tradeMethod}}</view>
|
||||
<view class="heading-eyebrow">邻里流转</view>
|
||||
<view class="heading-title">二手闲置</view>
|
||||
<view class="heading-subtitle">发布闲置,线下联系交易</view>
|
||||
</view>
|
||||
<button size="mini" class="primary publish-button" bindtap="goCreate">发布</button>
|
||||
</view>
|
||||
|
||||
<view class="search-row">
|
||||
<input class="input search-input" placeholder="搜索闲置" value="{{keyword}}" bindinput="onKeywordInput" confirm-type="search" bindconfirm="search" />
|
||||
<button size="mini" class="primary search-button" bindtap="search">搜索</button>
|
||||
</view>
|
||||
|
||||
<view class="second-grid">
|
||||
<view wx:for="{{goods}}" wx:key="id" class="second-card">
|
||||
<image wx:if="{{item.imageUrls.length}}" class="second-cover" src="{{item.imageUrls[0]}}" mode="aspectFill" />
|
||||
<view wx:else class="second-cover media-placeholder">闲</view>
|
||||
<view class="second-body">
|
||||
<view class="card-title">{{item.title}}</view>
|
||||
<view class="row between">
|
||||
<view class="pill">{{item.category}}</view>
|
||||
<view class="price">¥{{item.priceCent / 100}}</view>
|
||||
</view>
|
||||
<view class="muted">{{item.description}}</view>
|
||||
<button size="mini" class="ghost" data-phone="{{item.contactPhone}}" bindtap="callSeller">联系卖家</button>
|
||||
<view class="muted second-desc">{{item.description}}</view>
|
||||
<view class="row between">
|
||||
<view class="muted">{{item.tradeMethod}}</view>
|
||||
<button size="mini" class="ghost contact-button" data-phone="{{item.contactPhone}}" bindtap="callSeller">联系</button>
|
||||
</view>
|
||||
<view wx:if="{{!goods.length && !loading}}" class="card muted">暂无闲置</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!goods.length && !loading}}" class="empty-state">暂无闲置</view>
|
||||
</view>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user