Browse Source

fix: allow reapplication after withdrawal with isolated history and authorization

fix/mysql57-module-migrations-20261009
Zhang Shun 1 week ago
parent
commit
67b095724b
  1. 150
      docs/acceptance/application-reapplication-20261020/blackbox.py
  2. 25
      docs/acceptance/application-reapplication-20261020/verify_contract.py
  3. 67
      docs/application-reapplication-handover-20261020.md
  4. 20
      docs/openapi-rc.json
  5. 18
      mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java
  6. 4
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java
  7. 12
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java
  8. 22
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  9. 4
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java
  10. 4
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java
  11. 6
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java
  12. 3
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java
  13. 4
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java
  14. 10
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java
  15. 20
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java
  16. 12
      mydreamjob-server/src/main/resources/db/migration/V58__application_reapplication.sql
  17. 3
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationIntegrationTest.java
  18. 55
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMigrationTest.java
  19. 36
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMySqlIntegrationTest.java
  20. 111
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationTestBase.java
  21. 18
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java
  22. 6
      mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlMigrationTest.java

150
docs/acceptance/application-reapplication-20261020/blackbox.py

@ -0,0 +1,150 @@
"""重投 JAR 黑盒,仅固定本地测试容器/库。无生产目标参数,无真实短信。"""
import argparse,concurrent.futures,datetime,hashlib,json,os,pathlib,socket,subprocess,time,uuid
import requests,websocket
ROOT=pathlib.Path('D:/Workspaces/qsp-reapply-20261020')
API='http://127.0.0.1:18090/api/v1'
DB='qsp_reapply_1020'
PASSWORD='qsp-reapply-isolated-only'
NAMES=['qsp-reapply-1020-mysql','qsp-reapply-1020-redis','qsp-reapply-1020-rabbit']
RESULTS=[]
def save(name,data):
(ROOT/name).write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8')
def inspect(name):return json.loads(subprocess.check_output(['docker','inspect',name]))[0]
def sql(text):
i=inspect(NAMES[0]);assert i['Name']=='/'+NAMES[0] and i['HostConfig']['PortBindings']['3306/tcp'][0]['HostIp']=='127.0.0.1'
p=subprocess.run(['docker','exec','-i',NAMES[0],'mysql','-uroot','-p'+PASSWORD,'--default-character-set=utf8mb4','-NB',DB],input=text,capture_output=True,text=True,encoding='utf-8')
if p.returncode:raise RuntimeError(p.stderr[:400])
return [line.split('\t') for line in p.stdout.strip().splitlines() if line]
def check(name,ok,detail=None):
RESULTS.append({'case':name,'ok':bool(ok),'detail':detail});save('blackbox-results.json',RESULTS);print(name,bool(ok),flush=True)
if not ok:raise AssertionError(name)
def http(method,path,token=None,body=None,files=None):
return requests.request(method,API+path,headers={'Authorization':'Bearer '+token} if token else {},json=body,files=files,timeout=30)
def good(name,method,path,token=None,body=None):
r=http(method,path,token,body);j=r.json();check(name,r.status_code==200,{'status':r.status_code,'response':j});return j['data']
def denied(name,method,path,token,body,code):
expected={7001:404,8001:409,8303:403,8304:409,8309:409,8106:400,2002:403}[code]
r=http(method,path,token,body);j=r.json();check(name,r.status_code==expected and j.get('code')==code,{'expectedStatus':expected,'status':r.status_code,'response':j})
def login(phone):
sql("INSERT INTO sms_verification_codes(phone,code,purpose,attempts,max_attempts,expired_at,used,created_at) VALUES('%s','246810','login',0,5,DATE_ADD(NOW(),INTERVAL 1 DAY),0,NOW())"%phone)
r=http('POST','/auth/login',body={'phone':phone,'code':'246810','purpose':'login'})
if r.status_code!=200:raise RuntimeError('Isolated login failed')
return r.json()['data']['accessToken']
def setup():
for port in [3309,16390,15690,18090,18091]:
with socket.socket() as s:
if s.connect_ex(('127.0.0.1',port))==0:raise RuntimeError('Occupied port '+str(port))
for name in NAMES:
if subprocess.run(['docker','inspect',name],capture_output=True).returncode==0:raise RuntimeError('Existing container: '+name)
state={'containers':{},'volumes':[]}
save('environment.json',state)
for name,image,port,internal,memory,extra in [
(NAMES[0],'mysql:8.4',3309,3306,'768m',['-e','MYSQL_ROOT_PASSWORD='+PASSWORD,'-e','MYSQL_DATABASE='+DB]),
(NAMES[1],'redis:7.4-alpine',16390,6379,'128m',[]),
(NAMES[2],'rabbitmq:3.13-alpine',15690,5672,'256m',[])]:
cmd=['docker','run','-d','--name',name,'--label','qsp.acceptance=reapplication-20261020','--memory='+memory,'-p',f'127.0.0.1:{port}:{internal}']+extra+[image]
cid=subprocess.check_output(cmd,text=True).strip();state['containers'][name]=cid
state['volumes'].extend(m['Name'] for m in inspect(name)['Mounts'] if m['Type']=='volume');save('environment.json',state)
for _ in range(60):
try:sql('SELECT 1');return
except Exception:time.sleep(1)
raise RuntimeError('MySQL readiness timeout')
def start():
jar=ROOT/'mydreamjob-server/target/mydreamjob-server-0.0.1-SNAPSHOT.jar'
for name in ['logs','tmp','storage','resume-exports','data-exports']:(ROOT/name).mkdir(exist_ok=True)
env=os.environ.copy();env.update(DB_URL=f'jdbc:mysql://127.0.0.1:3309/{DB}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai',DB_USERNAME='root',DB_PASSWORD=PASSWORD,
REDIS_HOST='127.0.0.1',REDIS_PORT='16390',REDIS_PASSWORD='',RABBITMQ_HOST='127.0.0.1',RABBITMQ_PORT='15690',RABBITMQ_USERNAME='guest',RABBITMQ_PASSWORD='guest',
JWT_ACCESS_SECRET='reapply-isolated-access-secret-no-production-0001',JWT_REFRESH_SECRET='reapply-isolated-refresh-secret-no-production-001',JWT_TOKEN_HASH_SECRET='reapply-isolated-tokenhash-secret-no-production-01',
SMS_ALIYUN_ENABLED='false',SMS_MOCK_ENABLED='true',ADMIN_BOOTSTRAP_ENABLED='false',RESUME_STORAGE_DIR=str(ROOT/'storage'),RESUME_EXPORT_DIR=str(ROOT/'resume-exports'),DATA_EXPORT_DIR=str(ROOT/'data-exports'),TEMP=str(ROOT/'tmp'),TMP=str(ROOT/'tmp'))
cmd=['java','-Xms128m','-Xmx512m','-Djava.io.tmpdir='+str(ROOT/'tmp'),'-jar',str(jar),'--server.address=127.0.0.1','--server.port=18090','--management.server.port=18091',
'--app.offer.scheduler.enabled=false','--app.match.notification.enabled=false','--app.notification.mq-enabled=false','--app.risk.scan.enabled=false','--app.scoring.refresh.enabled=false','--app.scoring.refresh.compensation-enabled=false','--spring.rabbitmq.listener.simple.auto-startup=false']
p=subprocess.Popen(cmd,env=env,cwd=ROOT,stdout=open(ROOT/'logs/blackbox-app.log','ab'),stderr=open(ROOT/'logs/blackbox-app.err.log','ab'),creationflags=subprocess.CREATE_NO_WINDOW)
save('process.json',{'pid':p.pid,'jar':str(jar),'sha256':hashlib.sha256(jar.read_bytes()).hexdigest()})
for _ in range(60):
if p.poll() is not None:raise RuntimeError('App exited')
try:
if requests.get('http://127.0.0.1:18091/actuator/health',timeout=2).status_code==200:print('APP_READY',p.pid);return
except requests.RequestException:pass
time.sleep(1)
raise RuntimeError('App health timeout')
def run():
stamp=uuid.uuid4().hex[:8];suffix=uuid.uuid4().int%999999
phones=['19908'+str(suffix+i).zfill(6) for i in range(2)]
for phone,role in zip(phones,['STUDENT','COMPANY']):
sql("INSERT INTO users(phone,nickname,role,is_active,is_verified) VALUES('%s','synthetic-%s','%s',1,1)"%(phone,stamp,role))
sql("INSERT INTO user_roles(user_id,role) SELECT id,'%s' FROM users WHERE phone='%s'"%(role,phone))
student=int(sql("SELECT id FROM users WHERE phone='%s'"%phones[0])[0][0]);hr=int(sql("SELECT id FROM users WHERE phone='%s'"%phones[1])[0][0])
sql("INSERT INTO companies(owner_user_id,name,verified) VALUES(%d,'synthetic-reapply',1)"%hr)
ta,th=map(login,phones)
pdf=pathlib.Path('D:/Acceptance/qsp-filter-full-20261019-1/continuation-1/synthetic-resume.pdf').read_bytes()
r=http('POST','/profile/me/resume',ta,files={'file':('synthetic.pdf',pdf,'application/pdf')});check('real PDF upload',r.status_code==200)
good('privacy','PUT','/profile/me/privacy',ta,{'resumeVisibility':'APPLIED_COMPANIES','contactVisible':False})
job=good('create job','POST','/jobs',th,{'title':'synthetic-reapply','description':'not real recruitment','city':'TEST','employmentType':'FULL_TIME'})['id']
good('publish job','PATCH',f'/jobs/{job}/status',th,{'status':'PUBLISHED'})
def apply():return http('POST','/applications',ta,{'jobId':job})
def match(app):
good('candidate scores new round','PUT',f'/applications/{app}/candidate-interest-score',ta,{'score':90})
d=good('HR scores new round','PUT',f'/applications/{app}/hr-suitability-score',th,{'score':90});check('new round matched',d['status']=='MATCHED')
return good('matched conversation','POST',f'/applications/{app}/conversation',ta)['id']
a=apply().json()['data'];check('first round',a['attemptNumber']==1)
conv=match(a['id']);oldmsg=good('old resume card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':a['id'],'clientMessageId':'old-'+stamp})['id']
future=datetime.datetime.now()+datetime.timedelta(days=2)
interview=good('old interview fixture','POST',f"/applications/{a['id']}/interviews",th,{'title':'synthetic old interview','interviewType':'ONLINE','startAt':future.isoformat(),'endAt':(future+datetime.timedelta(hours=1)).isoformat(),'meetingUrl':'https://example.invalid/interview'})
offer=good('old draft offer fixture','POST','/offers',th,{'applicationId':a['id'],'title':'synthetic old offer','description':'not real employment','workCity':'TEST','expireAt':future.isoformat()})
oldurl=f'/conversations/{conv}/messages/{oldmsg}/resume'
good('withdraw first round','POST',f"/applications/{a['id']}/withdraw",ta)
denied('old card denied after withdrawal','GET',oldurl,th,None,8309)
denied('old company download denied','GET',f"/company/candidates/{a['id']}/resume",th,None,2002)
denied('old application cannot unlock','POST',f"/applications/{a['id']}/conversation",ta,None,8304)
denied('historical send locked','POST','/conversations/messages',ta,{'conversationId':conv,'content':'blocked'},8303)
good('history readable','GET',f'/conversations/{conv}/messages',ta)
b=apply().json()['data'];check('second id and lineage',b['id']!=a['id'] and b['attemptNumber']==2 and b['previousApplicationId']==a['id'],b)
d=good('new round interview list','GET',f"/applications/{b['id']}/interviews",th);check('interviews not copied',d==[],d)
d=good('new round offer list','GET',f"/offers?applicationId={b['id']}",th);check('offers not copied',d['total']==0,d)
denied('old offer cannot be sent after withdrawal','PATCH',f"/offers/{offer['id']}/status",th,{'status':'SENT'},8106)
denied('new round not auto-matched','POST',f"/applications/{b['id']}/conversation",ta,None,8304)
denied('new round cannot send inherited card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':b['id'],'clientMessageId':'pending-'+stamp},8303)
good('withdraw second round','POST',f"/applications/{b['id']}/withdraw",ta)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:responses=list(pool.map(lambda _:apply(),range(4)))
wins=[r for r in responses if r.status_code==200];losers=[r for r in responses if r.status_code!=200]
check('concurrent one winner three business duplicates',len(wins)==1 and len(losers)==3 and all(r.json().get('code')==8001 for r in losers),[{'status':r.status_code,'data':r.json()} for r in responses])
c=wins[0].json()['data'];check('third round lineage',c['attemptNumber']==3 and c['previousApplicationId']==b['id'],c)
newpdf=pdf+b'\n% new-round-current-resume\n';r=http('POST','/profile/me/resume',ta,files={'file':('current.pdf',newpdf,'application/pdf')});check('replace source before rematch',r.status_code==200)
check('rematch reuses historical chat',match(c['id'])==conv)
ws=websocket.create_connection('ws://127.0.0.1:18090/api/v1/ws',origin='http://localhost:3000',subprotocols=['v12.stomp'],timeout=10,http_proxy_host=None)
ws.send('CONNECT\naccept-version:1.2\nheart-beat:0,0\nAuthorization:Bearer '+th+'\n\n\x00');check('STOMP after rematch authenticated',ws.recv().startswith('CONNECTED'))
ws.send('SUBSCRIBE\nid:reapply\ndestination:/user/queue/chat-events\n\n\x00');check('STOMP subscription ready','CHAT_READY' in ws.recv())
text=good('send after fresh match','POST','/conversations/messages',ta,{'conversationId':conv,'content':'new round','clientMessageId':'new-'+stamp})
frame=ws.recv();event=json.loads(frame.split('\n\n',1)[1].rstrip('\x00'));check('new round websocket exact message',event.get('type')=='MESSAGE_CREATED' and event.get('payload',{}).get('id')==text['id'],event);ws.close()
denied('old card remains denied despite new match','GET',oldurl,th,None,8309)
newmsg=good('new round resume card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':c['id'],'clientMessageId':'resume-'+stamp})['id']
newurl=f'/conversations/{conv}/messages/{newmsg}/resume';r=http('GET',newurl,th);check('new card uses current PDF',r.status_code==200 and r.content==newpdf)
timeline=good('old timeline retained','GET',f"/applications/{a['id']}/timeline",ta);check('old withdrawal in timeline',any(x['toStatus']=='WITHDRAWN' for x in timeline))
page=good('student list','GET','/applications/me',ta);items=[x for x in page['items'] if x['jobId']==job];check('three rounds listed',len(items)==3 and {x['attemptNumber'] for x in items}=={1,2,3})
d=good('company candidate detail','GET',f"/company/candidates/{c['id']}",th);check('company detail lineage',d['attemptNumber']==3 and d['previousApplicationId']==b['id'],d)
d=good('company candidate list','GET',f'/company/candidates?jobId={job}',th);check('company list preserves all rounds',len(d['items'])==3 and {x['attemptNumber'] for x in d['items']}=={1,2,3},d)
closed=good('closed fixture job','POST','/jobs',th,{'title':'closed fixture','description':'synthetic','city':'TEST','employmentType':'FULL_TIME'})['id']
good('publish closed fixture','PATCH',f'/jobs/{closed}/status',th,{'status':'PUBLISHED'})
app=good('apply closed fixture','POST','/applications',ta,{'jobId':closed})['id'];good('withdraw closed fixture','POST',f'/applications/{app}/withdraw',ta)
good('close fixture job','PATCH',f'/jobs/{closed}/status',th,{'status':'CLOSED'})
denied('closed job blocks reapply','POST','/applications',ta,{'jobId':closed},7001)
rejected=good('rejected fixture job','POST','/jobs',th,{'title':'rejected fixture','description':'synthetic','city':'TEST','employmentType':'FULL_TIME'})['id']
good('publish rejected fixture','PATCH',f'/jobs/{rejected}/status',th,{'status':'PUBLISHED'})
app=good('apply rejected fixture','POST','/applications',ta,{'jobId':rejected})['id'];good('reject fixture application','PATCH',f'/applications/{app}/status',th,{'status':'REJECTED'})
denied('rejected still blocks reapply','POST','/applications',ta,{'jobId':rejected},8001)
save('blackbox-state.json',{'phones':phones,'job':job,'oldApplication':a['id'],'currentApplication':c['id'],'conversation':conv,'oldUrl':oldurl,'newUrl':newurl,'sha256':hashlib.sha256(newpdf).hexdigest()})
def recheck():
s=json.loads((ROOT/'blackbox-state.json').read_text(encoding='utf-8'));ta,th=map(login,s['phones'])
d=good('current application survives restart','GET',f"/applications/{s['currentApplication']}",ta);check('third round persists',d['attemptNumber']==3 and d['status']=='SUBMITTED')
d=good('old application survives restart','GET',f"/applications/{s['oldApplication']}",ta);check('withdrawn state persists',d['status']=='WITHDRAWN')
denied('old card still denied after restart','GET',s['oldUrl'],th,None,8309)
r=http('GET',s['newUrl'],th);check('new authorized bytes persist',r.status_code==200 and hashlib.sha256(r.content).hexdigest()==s['sha256'])
good('chat sending still unlocked by new match','POST','/conversations/messages',ta,{'conversationId':s['conversation'],'content':'restart','clientMessageId':'restart-'+uuid.uuid4().hex})
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('mode',choices=['setup','start','run','recheck']);a=parser.parse_args()
ROOT.mkdir(parents=True,exist_ok=True)
if a.mode in ['run','recheck']:
try:globals()[a.mode]()
finally:save('blackbox-'+a.mode+'-results.json',RESULTS)
else:globals()[a.mode]()

25
docs/acceptance/application-reapplication-20261020/verify_contract.py

@ -0,0 +1,25 @@
"""本批静态校验及当前隔离 JAR 的契约对照,不修改契约。"""
import json,pathlib,subprocess,sys,requests
ROOT=pathlib.Path('D:/Workspaces/qsp-reapply-20261020')
REPO=pathlib.Path(__file__).resolve().parents[3]
rows=[]
def check(name,ok,actual=None):rows.append({'case':name,'ok':bool(ok),'actual':actual})
p=subprocess.run([sys.executable,str(REPO/'docs/verify_openapi.py')],capture_output=True,text=True,encoding='utf-8',errors='replace',env={**__import__('os').environ,'PYTHONIOENCODING':'utf-8'})
(ROOT/'logs/static-openapi.log').write_text(p.stdout+p.stderr,encoding='utf-8');check('static verifier',p.returncode==0,p.returncode)
static=json.loads((REPO/'docs/openapi-rc.json').read_text(encoding='utf-8'))
r=requests.get('http://127.0.0.1:18090/api/v1/v3/api-docs',timeout=30);check('runtime OpenAPI reachable',r.status_code==200,r.status_code);runtime=r.json()
(ROOT/'runtime-openapi.json').write_text(json.dumps(runtime,ensure_ascii=False),encoding='utf-8')
check('same path identities',set(static['paths'])==set(runtime['paths']))
methods={'get','post','put','patch','delete','head','options','trace'}
ops=lambda doc:{(p,m) for p,vs in doc['paths'].items() for m in vs if m in methods}
check('same operation identities',ops(static)==ops(runtime))
for name in ['CandidateItemView','CandidateDetailView']:
for field,fmt in [('attemptNumber','int32'),('previousApplicationId','int64')]:
a=static['components']['schemas'][name]['properties'][field];b=runtime['components']['schemas'][name]['properties'][field]
check(name+'.'+field,a.get('type')==b.get('type')=='integer' and a.get('format')==b.get('format')==fmt,{'static':a,'runtime':b})
forbidden=set(runtime['components']['schemas'][name]['properties'])&{'studentUserId','phone','email','resumeUrl','storagePath'}
check(name+' remains redacted',not forbidden,sorted(forbidden))
for path in ['/applications','/applications/{id}/withdraw']:
check(path+' description',static['paths'][path]['post']['description']==runtime['paths'][path]['post']['description'])
(ROOT/'contract-results.json').write_text(json.dumps(rows,ensure_ascii=False,indent=2),encoding='utf-8')
print('CONTRACT',len(rows),'FAIL',sum(not r['ok'] for r in rows));raise SystemExit(any(not r['ok'] for r in rows))

67
docs/application-reapplication-handover-20261020.md

@ -0,0 +1,67 @@
# 撤回后重新投递:后端与前端联调约定
范围:仅后端重投规则、兼容字段和关联权限;不调整已拒绝后的重投政策,不修改前端,不删除历史数据。测试结果以本批最终验收记录为准;本说明不代表已经上线。
## 1. 重投规则
| 当前情况 | POST /applications |
| --- | --- |
| 没有投递历史 | 创建首轮,SUBMITTED |
| 同岗位所有历史申请均为 WITHDRAWN | 创建新 id、新轮次,SUBMITTED |
| 存在 SUBMITTED/VIEWED/INTERVIEW | 拒绝重复投递 |
| 存在 REJECTED/HIRED | 仍拒绝,本批不放开 |
| 岗位不是 PUBLISHED | 不允许投递,包括撤回后重投 |
撤回仍只允许 SUBMITTED/VIEWED,不放开面试中撤回。旧记录不恢复为 SUBMITTED;每轮有独立时间线,旧匹配、面试、Offer 及简历卡片继续关联旧 id。
V58 新增 attempt_number、previous_application_id 和按状态生成的 active_application_key。旧记录轮次为 1;仅 WITHDRAWN 的占位键为 NULL。数据库同时限制“同岗位同求职者同轮次唯一”和“同岗位同求职者非撤回申请唯一”。投递事务锁定岗位,检查其发布状态、占位及上一轮,防止并发重投。岗位级锁会串行处理同岗位投递,是本批明确的正确性取舍。
## 2. 接口字段与错误码
继续调用 `POST /api/v1/applications`,请求不变:
```json
{"jobId":123}
```
重投成功仍返回 HTTP 200,新响应 `data` 示例(其他既有字段保留):
```json
{"id":456,"jobId":123,"status":"SUBMITTED","attemptNumber":2,"previousApplicationId":321}
```
- `id`:本轮投递 ID。后续评分、企业处理、面试、Offer、会话解锁及简历卡片操作必须使用对应轮次的 ID。
- `attemptNumber`:从 1 开始;用于区分同岗位不同轮次,不代表新的投递状态。
- `previousApplicationId`:直接上一轮 id;首轮为 null,受全局非空序列化影响也可能省略。
- 上述字段下发于本人投递列表/详情、企业投递列表、企业候选人列表/详情。候选人 DTO 不新增手机号、用户 ID、简历存储引用等敏感字段。
- 重复投递沿用 HTTP 409 / `code=8001`,岗位不可投递沿用 HTTP 404 / `code=7001`。不要把所有非 200 都显示为“网络错误”。
- `code=8003`:撤回/状态流转不允许;`8304`:指定轮次未匹配;`8303`:企业与求职者当前没有有效匹配,不能建立/恢复发送。
前端撤回后将该轮显示为“已撤回”,在岗位仍可投递时展示“重新投递”。成功后使用服务端返回的新 id 刷新列表,不覆盖旧记录。防连点只用于体验,后端仍负责并发约束;请求超时后先刷新投递记录,不能擅自推断成功。
同岗位多轮全部保留在列表;判断最新轮次用该岗位的最大 attemptNumber,不依赖秒级 createdAt 相同情况下的数组顺序。
## 3. 旧会话与简历权限
- 新一轮不复制旧匹配分、阈值快照或匹配结果。旧 id 即使保留 MATCHED 历史记录,只要投递已撤回,不能通过该 id 解锁会话。
- 历史 DIRECT 会话可阅读。对有撤回历史的企业/求职者组合,发送消息和通过通用接口获取旧会话,必须存在双方当前有效 MATCHED 申请。重新匹配后可以复用原会话,不复制历史消息。
- 沟通解锁仍采用既有“企业—求职者关系”口径:若双方另一个岗位存在有效匹配,可以继续沟通;不能把它描述为每个岗位拥有完全独立的聊天权限。
- 聊天简历快照继续绑定原 applicationId,撤回后不能因新一轮匹配而恢复旧卡片授权;已显式撤销的卡片不复活。
- 本批不引入“投递时固化简历”新机制。企业当前简历下载仍读取求职者当前资料,并依据指定 applicationId、企业归属与隐私策略授权;主动发送卡片时才保存不可变快照。
- PRIVATE 为动态限制;VERIFIED_COMPANIES 等既有显式隐私授权语义不在本批调整。不可将用户面向认证企业的全局授权误称为旧轮次权限恢复。
- 面试和 Offer 不迁移到新 id,不恢复旧流程。
## 4. 前端联调验收步骤
1. 使用合成账号和已发布岗位首次投递,记录 id、轮次及列表状态。
2. 企业查看后,求职者撤回,确认旧时间线仍在。
3. 同一岗位重投,确认返回新 id、轮次加一、previousApplicationId 指向旧 id,企业收到新的待处理申请。
4. 连续点击/并发请求,确认最多一条有效申请,其余返回重复投递错误。
5. 对进行中、已拒绝、已录用和关闭岗位分别验证禁止重投。
6. 旧轮次匹配不能解锁新轮次;历史聊天可读,新轮次重新满足匹配后才按关系级规则发送。
7. 用真实结构的合成 PDF 验证旧卡片仍拒绝、新卡片绑定新轮次;更换源简历不能篡改旧快照。
8. 重启隔离 JAR 后核对两轮记录、时间线和授权行为。
发布前还需:真实 MySQL 5.7/8 迁移、专项回归、最新 JAR 黑盒与后续全量验证。前端接入与提交/推送/发布均另行安排。
本批本地验证记录(2026-10-20):专项 Maven 测试合计 129 项,0 失败/错误/跳过;新 JAR 黑盒 61 项及重启复验 7 项通过;静态与运行时契约专项 12 项通过。已覆盖真实 MySQL 5.7/8 的 V57→V58 升级及 MySQL 并发。证据与制品在 `D:\Workspaces\qsp-reapply-20261020`。本批完整 `mvn verify` 尚未执行;上述结果不代表线上已经更新。

20
docs/openapi-rc.json

@ -964,7 +964,7 @@
"投递"
],
"summary": "投递岗位",
"description": "只能投递 PUBLISHED 状态的岗位,不可重复投递",
"description": "只能投递 PUBLISHED 岗位;仅所有历史投递均已撤回时可重投。重投返回新 id、attemptNumber 和 previousApplicationId,不继承旧匹配或授权;进行中、已拒绝、已录用仍返回重复投递错误。",
"operationId": "apply",
"requestBody": {
"content": {
@ -1529,7 +1529,7 @@
"投递"
],
"summary": "撤回投递",
"description": "仅 SUBMITTED/VIEWED 状态可撤回,撤回后不可逆",
"description": "仅 SUBMITTED/VIEWED 可撤回;旧记录不恢复,可通过 POST /applications 创建新一轮投递。",
"operationId": "withdraw",
"parameters": [
{
@ -13793,6 +13793,14 @@
"CandidateDetailView": {
"type": "object",
"properties": {
"attemptNumber": {
"type": "integer",
"format": "int32"
},
"previousApplicationId": {
"type": "integer",
"format": "int64"
},
"applicationId": {
"type": "integer",
"format": "int64"
@ -13960,6 +13968,14 @@
"CandidateItemView": {
"type": "object",
"properties": {
"attemptNumber": {
"type": "integer",
"format": "int32"
},
"previousApplicationId": {
"type": "integer",
"format": "int64"
},
"applicationId": {
"type": "integer",
"format": "int64"

18
mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java

@ -20,8 +20,11 @@ import java.time.LocalDateTime;
@AllArgsConstructor
@Entity
@Table(name = "applications",
uniqueConstraints = @UniqueConstraint(name = "uk_applications_job_student",
columnNames = {"job_id", "student_user_id"}))
uniqueConstraints = {
@UniqueConstraint(name = "uk_applications_job_student_attempt",
columnNames = {"job_id", "student_user_id", "attempt_number"}),
@UniqueConstraint(name = "uk_applications_job_student_active",
columnNames = {"job_id", "student_user_id", "active_application_key"})})
@EntityListeners(AuditingEntityListener.class)
public class Application {
@ -35,6 +38,17 @@ public class Application {
@Column(name = "student_user_id", nullable = false)
private Long studentUserId;
@Column(name = "attempt_number", nullable = false)
@Builder.Default
private Integer attemptNumber = 1;
@Column(name = "previous_application_id")
private Long previousApplicationId;
/** 数据库按状态生成;被拒绝、已录用仍占位,不能借重投绕过终态规则。 */
@Column(name = "active_application_key", insertable = false, updatable = false)
private Integer activeApplicationKey;
@Column(name = "cover_letter", columnDefinition = "text")
private String coverLetter;

4
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java

@ -52,7 +52,7 @@ public class ApplicationController {
// ── 学生投递 ──────────────────────────────────────────────
@Operation(summary = "投递岗位", description = "只能投递 PUBLISHED 状态的岗位,不可重复投递")
@Operation(summary = "投递岗位", description = "只能投递 PUBLISHED 岗位;仅所有历史投递均已撤回时可重投。重投返回新 id、attemptNumber 和 previousApplicationId,不继承旧匹配或授权;进行中、已拒绝、已录用仍返回重复投递错误。")
@PostMapping("/applications")
public ResponseEntity<ApiResponse<Map<String, Object>>> apply(
@Valid @RequestBody ApplicationCreateRequest req,
@ -64,7 +64,7 @@ public class ApplicationController {
// ── 学生撤回投递 ──────────────────────────────────────────
@Operation(summary = "撤回投递", description = "仅 SUBMITTED/VIEWED 状态可撤回,撤回后不可逆")
@Operation(summary = "撤回投递", description = "仅 SUBMITTED/VIEWED 可撤回;旧记录不恢复,可通过 POST /applications 创建新一轮投递。")
@PostMapping("/applications/{id}/withdraw")
public ResponseEntity<ApiResponse<Map<String, Object>>> withdraw(
@PathVariable Long id,

12
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java

@ -11,7 +11,17 @@ import java.util.List;
@Repository
public interface ApplicationRepository extends JpaRepository<Application, Long> {
Optional<Application> findByJobIdAndStudentUserId(Long jobId, Long studentUserId);
/** MySQL REPEATABLE READ 下必须当前读,不能在等待岗位锁后继续读取旧快照。 */
@org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
Optional<Application> findFirstByJobIdAndStudentUserIdOrderByAttemptNumberDesc(Long jobId, Long studentUserId);
@org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
Optional<Application> findFirstByJobIdAndStudentUserIdAndStatusNotInOrderByAttemptNumberDesc(
Long jobId, Long studentUserId, java.util.Collection<String> statuses);
@org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
@org.springframework.data.jpa.repository.Query("select a from Application a where a.id = :id")
Optional<Application> findLockedById(@org.springframework.data.repository.query.Param("id") Long id);
List<Application> findByStudentUserIdOrderByCreatedAtDesc(Long studentUserId);

22
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java

@ -74,12 +74,18 @@ public class ApplicationService {
public Application apply(Long studentUserId, ApplicationCreateRequest req,
com.mydreamjob.audit.AuditContext ctx) {
assertRole(studentUserId, "STUDENT");
Job job = jobService.getJob(req.getJobId());
Job job = jobService.getJobForApplication(req.getJobId());
if (!"PUBLISHED".equals(job.getStatus())) throw new BusinessException(ErrorCode.JOB_NOT_FOUND);
if (applicationRepository.existsByJobIdAndStudentUserId(req.getJobId(), studentUserId))
if (applicationRepository.findFirstByJobIdAndStudentUserIdAndStatusNotInOrderByAttemptNumberDesc(
req.getJobId(), studentUserId, Set.of("WITHDRAWN")).isPresent())
throw new BusinessException(ErrorCode.APPLICATION_DUPLICATE);
Application previous = applicationRepository
.findFirstByJobIdAndStudentUserIdOrderByAttemptNumberDesc(req.getJobId(), studentUserId)
.orElse(null);
Application app = Application.builder()
.jobId(req.getJobId()).studentUserId(studentUserId)
.attemptNumber(previous == null ? 1 : Math.addExact(previous.getAttemptNumber(), 1))
.previousApplicationId(previous == null ? null : previous.getId())
.status(ApplicationStatus.SUBMITTED.name()).build();
app = applicationRepository.save(app);
recordHistory(app.getId(), studentUserId, null, "SUBMITTED", "Student applied");
@ -105,7 +111,7 @@ public class ApplicationService {
public Application withdraw(Long studentUserId, Long applicationId,
com.mydreamjob.audit.AuditContext ctx) {
assertRole(studentUserId, "STUDENT");
Application app = applicationRepository.findById(applicationId)
Application app = applicationRepository.findLockedById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if (!app.getStudentUserId().equals(studentUserId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
@ -239,7 +245,7 @@ public class ApplicationService {
public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req,
com.mydreamjob.audit.AuditContext ctx) {
jobService.requireVerifiedCompany(companyId);
Application app = applicationRepository.findById(applicationId)
Application app = applicationRepository.findLockedById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
if (!job.getCompanyId().equals(companyId))
@ -275,6 +281,8 @@ public class ApplicationService {
view.put("jobId", app.getJobId());
view.put("studentUserId", app.getStudentUserId());
view.put("status", app.getStatus());
view.put("attemptNumber", app.getAttemptNumber());
view.put("previousApplicationId", app.getPreviousApplicationId());
view.put("createdAt", app.getCreatedAt());
view.put("updatedAt", app.getUpdatedAt());
return view;
@ -336,7 +344,7 @@ public class ApplicationService {
String selectClause = "SELECT a.id, a.jobId, j.title, a.status, " +
"sp.realName, sp.school, sp.education, sp.graduationYear, " +
"u.avatarUrl, a.createdAt, a.updatedAt";
"u.avatarUrl, a.createdAt, a.updatedAt, a.attemptNumber, a.previousApplicationId";
String fromClause = "FROM Application a " +
"JOIN Job j ON a.jobId = j.id " +
"LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " +
@ -376,6 +384,8 @@ public class ApplicationService {
.candidateAvatarUrl((String) row[8])
.createdAt((LocalDateTime) row[9])
.updatedAt((LocalDateTime) row[10])
.attemptNumber((Integer) row[11])
.previousApplicationId((Long) row[12])
.build()).collect(Collectors.toList());
return CandidateListResponse.builder()
@ -433,6 +443,8 @@ public class ApplicationService {
return CandidateDetailView.builder()
.applicationId(app.getId())
.attemptNumber(app.getAttemptNumber())
.previousApplicationId(app.getPreviousApplicationId())
.jobId(app.getJobId())
.jobTitle(job.getTitle())
.jobCity(job.getCity())

4
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java

@ -24,6 +24,8 @@ public class CandidateDetailView {
private String jobCity;
private String jobEmploymentType;
private String status;
private Integer attemptNumber;
private Long previousApplicationId;
private String coverLetter;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@ -37,4 +39,4 @@ public class CandidateDetailView {
private String candidateEducation;
private Short candidateGraduationYear;
private List<String> candidateSkills;
}
}

4
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java

@ -20,6 +20,8 @@ public class CandidateItemView {
private Long jobId;
private String jobTitle;
private String status;
private Integer attemptNumber;
private Long previousApplicationId;
private String candidateName;
private String candidateSchool;
private String candidateEducation;
@ -27,4 +29,4 @@ public class CandidateItemView {
private String candidateAvatarUrl;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
}

6
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java

@ -13,6 +13,12 @@ public interface ApplicationMatchRecordRepository extends JpaRepository<Applicat
Optional<ApplicationMatchRecord> findByApplicationId(Long applicationId);
/** 撤回后的历史直聊只保留阅读,重新发送必须重新取得有效匹配。 */
@Query("SELECT COUNT(a) > 0 FROM Application a JOIN Job j ON j.id = a.jobId "
+ "WHERE j.companyId = :companyId AND a.studentUserId = :candidateId AND a.status = 'WITHDRAWN'")
boolean existsWithdrawnApplication(@Param("companyId") Long companyId,
@Param("candidateId") Long candidateId);
/** 企业(companyId)与候选人(candidateId)之间是否存在已 MATCHED 的投递(用于建会话守卫) */
@Query("SELECT COUNT(r) > 0 FROM ApplicationMatchRecord r "
+ "JOIN Application a ON a.id = r.applicationId "

3
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java

@ -67,6 +67,9 @@ public class MatchConversationService {
}
// 必须已 MATCHED
if ("WITHDRAWN".equals(app.getStatus()) || "REJECTED".equals(app.getStatus())) {
throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED);
}
ApplicationMatchRecord record = recordRepository.findByApplicationId(applicationId).orElse(null);
if (record == null || !ApplicationMatchRecord.STATUS_MATCHED.equals(record.getStatus())) {
throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED);

4
mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java

@ -14,6 +14,10 @@ import java.util.Optional;
@Repository
public interface JobRepository extends JpaRepository<Job, Long>, JpaSpecificationExecutor<Job> {
@org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
@Query("select j from Job j where j.id = :id")
Optional<Job> findLockedById(@Param("id") Long id);
/** 企业资源访问使用归属限定查询,避免取出其他企业的岗位后再区分归属。 */
Optional<Job> findByIdAndCompanyId(Long id, Long companyId);

10
mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java

@ -73,9 +73,13 @@ public class JobService {
return jobRepository.save(job);
}
/**
* 查询岗位详情(含非 PUBLISHED 状态)
*/
/** 必须由投递事务调用;锁定岗位,串行检查发布状态及同岗位重投。 */
public Job getJobForApplication(Long jobId) {
return jobRepository.findLockedById(jobId)
.orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND));
}
/** 查询岗位详情(含非 PUBLISHED 状态)。 */
public Job getJob(Long jobId) {
return jobRepository.findById(jobId)
.orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND));

20
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java

@ -107,6 +107,7 @@ public class MessagingService {
Long otherId = req.getParticipantUserId();
if (userId.equals(otherId)) throw new BusinessException(ErrorCode.BAD_REQUEST);
if (!userRepository.existsById(otherId)) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
assertWithdrawnPairUnlocked(userId, otherId);
// 已有 DIRECT 会话 → 返回原会话(历史会话保持可访问,不删除、不失效)
List<ConversationMember> my = memberRepository.findByUserId(userId);
for (ConversationMember m : my) {
@ -155,6 +156,24 @@ public class MessagingService {
return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role);
}
/** 不删除历史会话;有撤回历史的企业/求职者必须重新取得有效匹配才能发送。 */
private void assertWithdrawnPairUnlocked(Long userId, Long otherId) {
User me = userRepository.findById(userId).orElse(null);
User other = userRepository.findById(otherId).orElse(null);
if (me == null || other == null) return;
boolean forward = hasRole(me, User.Role.COMPANY) && hasRole(other, User.Role.STUDENT);
boolean reverse = hasRole(me, User.Role.STUDENT) && hasRole(other, User.Role.COMPANY);
if (!forward && !reverse) return;
Long companyUser = forward ? userId : otherId;
Long student = forward ? otherId : userId;
Long companyId = companyRepository.findByOwnerUserId(companyUser).map(Company::getId).orElse(null);
if (companyId != null
&& applicationMatchRecordRepository.existsWithdrawnApplication(companyId, student)
&& !applicationMatchRecordRepository.existsMatchedApplication(companyId, student)) {
throw new BusinessException(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION);
}
}
private void requireVerifiedIfCompany(Long userId) {
User actor = userRepository.findByIdWithRoles(userId).orElse(null);
VerifiedCompanyGuard.requireVerifiedIfCompany(actor,
@ -202,6 +221,7 @@ public class MessagingService {
for (ConversationMember m : members) {
if (!m.getUserId().equals(userId)) {
assertNotBlockedBetween(userId, m.getUserId());
assertWithdrawnPairUnlocked(userId, m.getUserId());
}
}
if (req.getClientMessageId() != null) {

12
mydreamjob-server/src/main/resources/db/migration/V58__application_reapplication.sql

@ -0,0 +1,12 @@
-- 撤回保留历史,新一轮使用新的 application id。仅 WITHDRAWN 释放占位。
ALTER TABLE applications ADD COLUMN attempt_number INT NOT NULL DEFAULT 1;
ALTER TABLE applications ADD COLUMN previous_application_id BIGINT NULL;
-- MySQL 5.7/8 默认 VIRTUAL;省略 STORED,兼容 H2 MODE=MySQL 的真实迁移测试。
ALTER TABLE applications ADD COLUMN active_application_key INT GENERATED ALWAYS AS
(CASE WHEN status = 'WITHDRAWN' THEN NULL ELSE 1 END);
ALTER TABLE applications ADD CONSTRAINT uk_applications_job_student_attempt
UNIQUE (job_id, student_user_id, attempt_number);
ALTER TABLE applications ADD CONSTRAINT uk_applications_job_student_active
UNIQUE (job_id, student_user_id, active_application_key);
-- 新索引已覆盖 job 外键所需的前缀,随后移除旧的永久唯一限制。
ALTER TABLE applications DROP INDEX uk_applications_job_student;

3
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationIntegrationTest.java

@ -0,0 +1,3 @@
package com.mydreamjob.application;
@org.springframework.test.context.ActiveProfiles("integration-test")
class ApplicationReapplicationIntegrationTest extends ApplicationReapplicationTestBase {}

55
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMigrationTest.java

@ -0,0 +1,55 @@
package com.mydreamjob.application;
import com.mydreamjob.config.MySql57MigrationConfiguration;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.MySQLContainer;
import java.sql.*;
import static org.junit.jupiter.api.Assertions.*;
/** 真实 MySQL 5.7/8:V57 原数据升级、生成占位、唯一约束和旧关联保留。 */
class ApplicationReapplicationMigrationTest {
@Test void mysql57Upgrade() throws Exception { verify("mysql:5.7.36"); }
@Test void mysql8Upgrade() throws Exception { verify("mysql:8.4"); }
Flyway flyway(String url,String user,String pass,String target) {
var config=Flyway.configure().dataSource(url,user,pass).locations("classpath:db/migration");
if(target!=null) config.target(target);
MySql57MigrationConfiguration.configure(config);
return config.load();
}
void verify(String image) throws Exception {
try(var mysql=new MySQLContainer<>(image).withDatabaseName("qsp_reapply_migration")
.withUsername("reapply").withPassword("isolated-only")
.withCreateContainerCmdModifier(cmd->cmd.getHostConfig().withMemory(768L*1024*1024))) {
mysql.start();String base=mysql.getJdbcUrl();
String url=base+(base.contains("?")?"&":"?")+"characterEncoding=UTF-8";
try(var c=DriverManager.getConnection(url,mysql.getUsername(),mysql.getPassword());var s=c.createStatement()) {
s.execute("ALTER DATABASE qsp_reapply_migration CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
flyway(url,mysql.getUsername(),mysql.getPassword(),"57").migrate();
s.execute("INSERT INTO users(id,phone,role,is_active,is_verified) VALUES(990001,'19908000001','COMPANY',1,1),(990002,'19908000002','STUDENT',1,1)");
s.execute("INSERT INTO companies(id,owner_user_id,name,verified) VALUES(990001,990001,'synthetic migration',1)");
s.execute("INSERT INTO jobs(id,company_id,title,description,city,employment_type,status) VALUES(990001,990001,'synthetic','test','test','FULL_TIME','PUBLISHED'),(990002,990001,'blocked','test','test','FULL_TIME','PUBLISHED')");
s.execute("INSERT INTO applications(id,job_id,student_user_id,status) VALUES(990001,990001,990002,'WITHDRAWN'),(990002,990002,990002,'REJECTED')");
s.execute("INSERT INTO application_status_history(application_id,operator_user_id,from_status,to_status) VALUES(990001,990002,'SUBMITTED','WITHDRAWN')");
s.execute("INSERT INTO application_match_records(application_id,job_id,company_id,candidate_id,status) VALUES(990001,990001,990001,990002,'MATCHED')");
assertEquals(1,flyway(url,mysql.getUsername(),mysql.getPassword(),null).migrate().migrationsExecuted);
assertEquals("WITHDRAWN",scalar(s,"SELECT status FROM applications WHERE id=990001"));
assertEquals("1",scalar(s,"SELECT attempt_number FROM applications WHERE id=990001"));
assertNull(scalar(s,"SELECT active_application_key FROM applications WHERE id=990001"));
assertEquals("1",scalar(s,"SELECT active_application_key FROM applications WHERE id=990002"));
assertEquals("MATCHED",scalar(s,"SELECT status FROM application_match_records WHERE application_id=990001"));
assertEquals("1",scalar(s,"SELECT COUNT(*) FROM application_status_history WHERE application_id=990001"));
s.execute("INSERT INTO applications(id,job_id,student_user_id,status,attempt_number,previous_application_id) VALUES(990003,990001,990002,'SUBMITTED',2,990001)");
assertThrows(SQLException.class,()->s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number) VALUES(990001,990002,'SUBMITTED',3)"));
assertThrows(SQLException.class,()->s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number) VALUES(990002,990002,'SUBMITTED',2)"));
s.execute("UPDATE applications SET status='WITHDRAWN' WHERE id=990003");
s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number,previous_application_id) VALUES(990001,990002,'SUBMITTED',3,990003)");
assertThrows(SQLException.class,()->s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number) VALUES(990001,990002,'WITHDRAWN',2)"));
assertEquals("3",scalar(s,"SELECT COUNT(*) FROM applications WHERE job_id=990001"));
flyway(url,mysql.getUsername(),mysql.getPassword(),null).validate();
assertEquals(0,flyway(url,mysql.getUsername(),mysql.getPassword(),null).migrate().migrationsExecuted);
}
}
}
String scalar(Statement s,String sql) throws SQLException {try(var r=s.executeQuery(sql)){assertTrue(r.next());return r.getString(1);}}
}

36
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMySqlIntegrationTest.java

@ -0,0 +1,36 @@
package com.mydreamjob.application;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.*;
import org.springframework.transaction.annotation.*;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.*;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
@ActiveProfiles("mysql-it") @Testcontainers(disabledWithoutDocker=false)
@org.springframework.test.annotation.DirtiesContext(classMode=org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS)
class ApplicationReapplicationMySqlIntegrationTest extends ApplicationReapplicationTestBase {
@Container static final MySQLContainer<?> MYSQL=new MySQLContainer<>("mysql:8.4")
.withDatabaseName("qsp_reapply_test").withUsername("reapply").withPassword("isolated-test-only");
@DynamicPropertySource static void properties(DynamicPropertyRegistry r) {
r.add("spring.datasource.url",MYSQL::getJdbcUrl);r.add("spring.datasource.username",MYSQL::getUsername);r.add("spring.datasource.password",MYSQL::getPassword);
}
@Test @Transactional(propagation=Propagation.NOT_SUPPORTED)
void concurrentReapplicationHasExactlyOneWinner() throws Exception {
var original=apply();service.withdraw(student.getId(),original.getId());
var ready=new CountDownLatch(4);var start=new CountDownLatch(1);var pool=Executors.newFixedThreadPool(4);
try {
var futures=new java.util.ArrayList<Future<Boolean>>();
for(int i=0;i<4;i++) futures.add(pool.submit(()->{ready.countDown();assertTrue(start.await(10,TimeUnit.SECONDS));
try {apply();return true;} catch(com.mydreamjob.common.exception.BusinessException e) {
assertEquals(com.mydreamjob.common.exception.ErrorCode.APPLICATION_DUPLICATE.getCode(),e.getErrorCodeInt());return false;}}));
assertTrue(ready.await(10,TimeUnit.SECONDS));start.countDown();int winners=0;
for(var f:futures) if(f.get(30,TimeUnit.SECONDS)) winners++;
assertEquals(1,winners);
var all=applications.findByJobIdOrderByCreatedAtDesc(job.getId());assertEquals(2,all.size());
assertEquals(1,all.stream().filter(x->"SUBMITTED".equals(x.getStatus())).count());
} finally {start.countDown();pool.shutdownNow();}
}
}

111
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationTestBase.java

@ -0,0 +1,111 @@
package com.mydreamjob.application;
import com.mydreamjob.auth.*;
import com.mydreamjob.company.*;
import com.mydreamjob.job.*;
import com.mydreamjob.application.dto.ApplicationCreateRequest;
import com.mydreamjob.common.exception.*;
import com.mydreamjob.bidirectionalmatch.*;
import com.mydreamjob.messaging.*;
import com.mydreamjob.messaging.dto.*;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest @AutoConfigureMockMvc @Transactional
abstract class ApplicationReapplicationTestBase {
@Autowired ApplicationService service;
@Autowired ApplicationRepository applications;
@Autowired ApplicationStatusHistoryRepository history;
@Autowired UserRepository users;
@Autowired CompanyRepository companies;
@Autowired JobRepository jobs;
@Autowired EntityManager em;
@Autowired ApplicationMatchRecordRepository matches;
@Autowired MatchConversationService matchConversations;
@Autowired MessagingService messaging;
@Autowired ConversationRepository conversations;
@Autowired ConversationMemberRepository members;
@Autowired AuthSessionService sessions;
@Autowired java.time.Clock clock;
@Autowired MockMvc mvc;
@MockBean RedisTemplate<String,Object> redis;
User student, hr; Company company; Job job;
@BeforeEach void fixture() {
student=user(User.Role.STUDENT);hr=user(User.Role.COMPANY);
company=companies.saveAndFlush(Company.builder().ownerUserId(hr.getId()).name("重投隔离测试").verified(true).build());
job=jobs.saveAndFlush(Job.builder().companyId(company.getId()).title("重投测试岗位").description("合成数据")
.status("PUBLISHED").employmentType("FULL_TIME").city("测试").build());
}
User user(User.Role role) {
return users.saveAndFlush(User.builder().phone("19"+String.format("%09d",Math.abs(UUID.randomUUID().getMostSignificantBits()%1000000000)))
.role(role).roles(Set.of(role)).active(true).verified(true).build());
}
Application apply() {var req=new ApplicationCreateRequest();req.setJobId(job.getId());return service.apply(student.getId(),req);}
void error(ErrorCode code,org.junit.jupiter.api.function.Executable f) {
assertEquals(code.getCode(),assertThrows(BusinessException.class,f).getErrorCodeInt());
}
String bearer(User u) {return "Bearer "+sessions.createSession(u,clock.instant(),
com.mydreamjob.audit.AuditContext.builder().requestId("reapplication-test").build()).accessToken();}
@Test void withdrawalCreatesNewIdAndPreservesBothTimelines() {
var first=apply();service.withdraw(student.getId(),first.getId());em.flush();em.clear();
var second=apply();assertNotEquals(first.getId(),second.getId());assertEquals(2,second.getAttemptNumber());
assertEquals(first.getId(),second.getPreviousApplicationId());assertEquals("SUBMITTED",second.getStatus());
assertEquals("WITHDRAWN",applications.findById(first.getId()).orElseThrow().getStatus());
assertEquals(2,history.findByApplicationIdOrderByCreatedAtAsc(first.getId()).size());
assertEquals(1,history.findByApplicationIdOrderByCreatedAtAsc(second.getId()).size());
assertTrue(matches.findByApplicationId(second.getId()).isEmpty());
error(ErrorCode.APPLICATION_DUPLICATE,this::apply);
}
@Test void allNonWithdrawnStatesStillBlockReapplication() {
var a=apply();
for(String state:List.of("SUBMITTED","VIEWED","INTERVIEW","REJECTED","HIRED")) {
a.setStatus(state);applications.saveAndFlush(a);error(ErrorCode.APPLICATION_DUPLICATE,this::apply);
}
}
@Test void closedJobAndOtherStudentCannotBypassRules() {
var a=apply();var other=user(User.Role.STUDENT);
error(ErrorCode.APPLICATION_OWNER_MISMATCH,()->service.withdraw(other.getId(),a.getId()));
service.withdraw(student.getId(),a.getId());job.setStatus("CLOSED");jobs.saveAndFlush(job);
error(ErrorCode.JOB_NOT_FOUND,this::apply);
}
@Test void listsAndHttpExposeNewAttemptWithoutReplacingHistory() throws Exception {
var a=apply();service.withdraw(student.getId(),a.getId());var b=apply();
var view=service.listStudentApplicationViews(student.getId(),null,0,20);
assertEquals(2,view.getTotalElements());assertTrue(view.stream().anyMatch(x->Integer.valueOf(2).equals(x.get("attemptNumber"))));
var candidatePage=service.listCompanyCandidates(company.getId(),job.getId(),null,null,null,null,0,20);
assertEquals(Set.of(1,2),candidatePage.getItems().stream().map(com.mydreamjob.application.dto.CandidateItemView::getAttemptNumber).collect(java.util.stream.Collectors.toSet()));
mvc.perform(get("/applications/"+b.getId()).header("Authorization",bearer(student)))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.attemptNumber").value(2))
.andExpect(jsonPath("$.data.previousApplicationId").value(a.getId()));
}
@Test void historicalMatchCannotUnlockNewAttemptOrExistingChatUntilRematched() {
var a=apply();matches.saveAndFlush(ApplicationMatchRecord.builder().applicationId(a.getId()).jobId(job.getId())
.companyId(company.getId()).candidateId(student.getId()).status("MATCHED").candidateInterestScore(90).hrSuitabilityScore(90)
.companyRequiredCandidateScore(60).studentRequiredHrScore(60).build());
var conv=matchConversations.createOrGetMatchConversation(student.getId(),a.getId());long conversation=((Number)conv.get("id")).longValue();
service.withdraw(student.getId(),a.getId());var next=apply();
error(ErrorCode.APPLICATION_NOT_MATCHED,()->matchConversations.createOrGetMatchConversation(student.getId(),a.getId()));
error(ErrorCode.APPLICATION_NOT_MATCHED,()->matchConversations.createOrGetMatchConversation(student.getId(),next.getId()));
var send=new SendMessageRequest();send.setConversationId(conversation);send.setContent("不能沿用旧匹配");
error(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,()->messaging.sendMessage(student.getId(),send));
var create=new CreateConversationRequest();create.setParticipantUserId(student.getId());
error(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,()->messaging.createOrGetDirectConversation(hr.getId(),create));
assertNotNull(messaging.getConversation(conversation,student.getId()),"历史仍可阅读");
matches.saveAndFlush(ApplicationMatchRecord.builder().applicationId(next.getId()).jobId(job.getId())
.companyId(company.getId()).candidateId(student.getId()).status("MATCHED").candidateInterestScore(90).hrSuitabilityScore(90)
.companyRequiredCandidateScore(60).studentRequiredHrScore(60).build());
assertNotNull(messaging.sendMessage(student.getId(),send).get("id"));
}
}

18
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java

@ -73,7 +73,7 @@ class ApplicationServiceTest {
@Test void withdraw_submitted_shouldSucceed() {
mockStudentRole();
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application));
when(applicationRepository.findLockedById(APP_ID)).thenReturn(Optional.of(application));
when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@ -84,7 +84,7 @@ class ApplicationServiceTest {
@Test void withdraw_wrongStudent_shouldReturn403() {
mockStudentRole();
application.setStudentUserId(999L);
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application));
when(applicationRepository.findLockedById(APP_ID)).thenReturn(Optional.of(application));
BusinessException ex = assertThrows(BusinessException.class,
() -> applicationService.withdraw(STUDENT_ID, APP_ID));
@ -94,7 +94,7 @@ class ApplicationServiceTest {
@Test void withdraw_alreadyWithdrawn_shouldReturn409() {
mockStudentRole();
application.setStatus("WITHDRAWN");
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application));
when(applicationRepository.findLockedById(APP_ID)).thenReturn(Optional.of(application));
BusinessException ex = assertThrows(BusinessException.class,
() -> applicationService.withdraw(STUDENT_ID, APP_ID));
@ -104,7 +104,7 @@ class ApplicationServiceTest {
@Test void withdraw_rejected_shouldReturn409() {
mockStudentRole();
application.setStatus("REJECTED");
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application));
when(applicationRepository.findLockedById(APP_ID)).thenReturn(Optional.of(application));
BusinessException ex = assertThrows(BusinessException.class,
() -> applicationService.withdraw(STUDENT_ID, APP_ID));
@ -141,8 +141,7 @@ class ApplicationServiceTest {
@Test void apply_shouldRecordHistory() {
mockStudentRole();
ApplicationCreateRequest req = new ApplicationCreateRequest(); req.setJobId(JOB_ID);
when(jobService.getJob(JOB_ID)).thenReturn(publishedJob);
when(applicationRepository.existsByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)).thenReturn(false);
when(jobService.getJobForApplication(JOB_ID)).thenReturn(publishedJob);
when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@ -154,7 +153,7 @@ class ApplicationServiceTest {
@Test void updateStatus_shouldRecordHistory() {
ApplicationStatusRequest req = new ApplicationStatusRequest(); req.setStatus("VIEWED");
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application));
when(applicationRepository.findLockedById(APP_ID)).thenReturn(Optional.of(application));
when(jobService.getJob(JOB_ID)).thenReturn(publishedJob);
when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@ -168,8 +167,7 @@ class ApplicationServiceTest {
@Test void apply_shouldSucceed() {
mockStudentRole();
ApplicationCreateRequest req = new ApplicationCreateRequest(); req.setJobId(JOB_ID);
when(jobService.getJob(JOB_ID)).thenReturn(publishedJob);
when(applicationRepository.existsByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)).thenReturn(false);
when(jobService.getJobForApplication(JOB_ID)).thenReturn(publishedJob);
when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@ -197,7 +195,7 @@ class ApplicationServiceTest {
String name, String school, String edu, Short gradYear,
String avatarUrl) {
return new Object[]{appId, jobId, jobTitle, status, name, school, edu, gradYear, avatarUrl,
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()};
java.time.LocalDateTime.now(), java.time.LocalDateTime.now(), 1, null};
}
@Test void listCompanyCandidates_shouldReturnPaginatedResults() {

6
mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlMigrationTest.java

@ -81,7 +81,7 @@ class JobCompanyDisplayFieldsMysqlMigrationTest {
}
// 3) 升级到 V57
MigrateResult latest = flyway(url, user, pass, null).migrate();
MigrateResult latest = flyway(url, user, pass, "57").migrate();
assertTrue(latest.success, "升级到 V57 应成功");
assertEquals(1, latest.migrationsExecuted, "从 V56 升级应只执行一条迁移(V57)");
@ -117,7 +117,7 @@ class JobCompanyDisplayFieldsMysqlMigrationTest {
assertNull(scalar(url, user, pass, "SELECT salary_months FROM jobs WHERE title='B01旧岗位'"));
// 6) 重复迁移无待执行项;V57 只有一条历史且成功;新增列不重复
MigrateResult again = flyway(url, user, pass, null).migrate();
MigrateResult again = flyway(url, user, pass, "57").migrate();
assertEquals(0, again.migrationsExecuted, "再次迁移应无待执行项");
assertEquals(1, count(url, user, pass,
"SELECT count(*) FROM flyway_schema_history WHERE version='57' AND success=1"),
@ -140,7 +140,7 @@ class JobCompanyDisplayFieldsMysqlMigrationTest {
String pass = empty.getPassword();
ensureUtf8mb4(url, user, pass, empty.getDatabaseName());
MigrateResult fromScratch = flyway(url, user, pass, null).migrate();
MigrateResult fromScratch = flyway(url, user, pass, "57").migrate();
assertTrue(fromScratch.success, "空库应能完整迁移到 V57");
assertEquals("57", scalar(url, user, pass,
"SELECT version FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 1"),

Loading…
Cancel
Save