feat: harden phase one workflows

This commit is contained in:
王鹏
2026-07-14 16:04:51 +08:00
parent 47f4a24760
commit 767534df48
103 changed files with 4849 additions and 388 deletions

1
.gitignore vendored
View File

@@ -22,3 +22,4 @@ backend/logs/
deploy/.data/
miniapp/miniprogram_npm/
miniapp/private.*
**/project.private.config.json

View File

@@ -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 {

View File

@@ -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')
}

View File

@@ -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
})
})
})

View File

@@ -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`)
}

View 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
})
})
})

View File

@@ -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>) {

View File

@@ -140,3 +140,9 @@ body {
display: inline-flex;
gap: 8px;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
padding-top: 14px;
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View 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)));
}
}

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -0,0 +1,5 @@
package com.linhelp.common.security;
public interface LoginSessionIssuer {
LoginResponse issue(Long userId, Long tenantId, Long communityId, String roleCode);
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
package com.linhelp.common.security;
public interface MiniappSessionClient {
String resolveOpenId(String code);
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,7 @@
package com.linhelp.common.security.account;
import java.util.Optional;
public interface AdminAccountRepository {
Optional<AdminAccount> findByUsername(String username);
}

View File

@@ -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")
);
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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;

View File

@@ -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()));
}
}

View File

@@ -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());

View File

@@ -0,0 +1,9 @@
package com.linhelp.express;
import java.util.List;
public interface ExpressOrderStore {
List<ExpressOrderResponse> loadAll();
void save(ExpressOrderResponse order);
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,9 @@
package com.linhelp.groupbuy;
import java.util.List;
public interface GroupBuyOrderStore {
List<GroupBuyOrderResponse> loadAll();
void save(GroupBuyOrderResponse order);
}

View File

@@ -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());

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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()) {

View File

@@ -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));
}
}

View File

@@ -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());

View File

@@ -0,0 +1,9 @@
package com.linhelp.order;
import java.util.List;
public interface GoodsOrderStore {
List<GoodsOrderResponse> loadAll();
void save(GoodsOrderResponse order);
}

View 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);
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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));
}
}

View File

@@ -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) {

View File

@@ -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")

View File

@@ -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

View File

@@ -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 "";

View File

@@ -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);
}
}

View File

@@ -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:

View File

@@ -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:

View File

@@ -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;

View File

@@ -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);

View File

@@ -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();
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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"));

View File

@@ -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("文件大小不能超过");
}
}

View File

@@ -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
}

View 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: '苹果'
})
})
})

View File

@@ -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
}

View 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: '儿童'
})
})
})

View File

@@ -1,6 +1,7 @@
App({
globalData: {
apiBaseUrl: 'http://localhost:8080',
useDevLogin: true,
token: '',
user: null
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;

View 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)
})
})
})

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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' }))
},

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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' })
},

View File

@@ -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